plexsonic 0.1.11 → 0.1.13

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.13",
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) =>
@@ -712,12 +716,12 @@ function groupArtistsForSubsonic(artists) {
712
716
  name: artist.name,
713
717
  albumCount: artistAlbumCountValue(artist.artist),
714
718
  coverArt: artist.id,
719
+ roles: ['artist'],
715
720
  ...subsonicRatingAttrs(artist.artist),
716
721
  }),
717
- )
718
- .join('');
722
+ );
719
723
 
720
- return node('index', { name: key }, artistsXml);
724
+ return node('index', { name: key }, artistNodes);
721
725
  });
722
726
  }
723
727
 
@@ -752,7 +756,7 @@ function groupNamedEntriesForSubsonic(entries) {
752
756
  });
753
757
 
754
758
  return keys.map((key) => {
755
- const itemsXml = groups
759
+ const items = groups
756
760
  .get(key)
757
761
  .sort((a, b) => a.name.localeCompare(b.name))
758
762
  .map((item) =>
@@ -761,10 +765,9 @@ function groupNamedEntriesForSubsonic(entries) {
761
765
  name: item.name,
762
766
  coverArt: item.coverArt,
763
767
  }),
764
- )
765
- .join('');
768
+ );
766
769
 
767
- return node('index', { name: key }, itemsXml);
770
+ return node('index', { name: key }, items);
768
771
  });
769
772
  }
770
773
 
@@ -1071,6 +1074,50 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
1071
1074
  };
1072
1075
  }
1073
1076
 
1077
+ function buildArtistEntry(idCandidate, nameCandidate) {
1078
+ const name = firstNonEmptyText([nameCandidate], '');
1079
+ if (!name) {
1080
+ return null;
1081
+ }
1082
+ const id = String(idCandidate || '').trim();
1083
+ return {
1084
+ id: id || undefined,
1085
+ name,
1086
+ };
1087
+ }
1088
+
1089
+ function albumArtistEntries(album, fallbackArtistId = null, fallbackArtistName = null) {
1090
+ const artistId = firstNonEmptyText(
1091
+ [album?.parentRatingKey, album?.artistId, fallbackArtistId, album?.grandparentRatingKey],
1092
+ '',
1093
+ );
1094
+ const artistName = firstNonEmptyText(
1095
+ [album?.parentTitle, album?.artist, fallbackArtistName, album?.grandparentTitle],
1096
+ '',
1097
+ );
1098
+ const entry = buildArtistEntry(artistId, artistName);
1099
+ return entry ? [entry] : [];
1100
+ }
1101
+
1102
+ function renderArtistListNode(listName, entries, options = {}) {
1103
+ if (!entries || entries.length === 0) {
1104
+ return null;
1105
+ }
1106
+ const artistNodes = entries.map((entry) => emptyNode('artist', { id: entry.id, name: entry.name }));
1107
+ const attrs = options.flatten ? { flatten: 'true' } : {};
1108
+ return node(listName, attrs, artistNodes);
1109
+ }
1110
+
1111
+ function albumNode(name, album, attrs, fallbackArtistId = null, fallbackArtistName = null, extra = null) {
1112
+ const artistEntries = albumArtistEntries(album, fallbackArtistId, fallbackArtistName);
1113
+ const artistsNode = renderArtistListNode('artists', artistEntries, { flatten: true });
1114
+ const inner = [artistsNode, extra].filter(Boolean);
1115
+ if (inner.length === 0) {
1116
+ return emptyNode(name, attrs);
1117
+ }
1118
+ return node(name, attrs, inner);
1119
+ }
1120
+
1074
1121
  function artistAlbumCountValue(artist) {
1075
1122
  return parseNonNegativeInt(
1076
1123
  artist?.albumCount ?? artist?.childCount ?? artist?.album_count ?? artist?.leafCount,
@@ -1164,7 +1211,7 @@ function songAttrs(track, albumTitle = null, albumCoverArt = null, albumMetadata
1164
1211
  'Unknown Album',
1165
1212
  );
1166
1213
  const normalizedArtist = firstNonEmptyText(
1167
- [track?.grandparentTitle, track?.originalTitle],
1214
+ [track?.originalTitle, track?.grandparentTitle],
1168
1215
  'Unknown Artist',
1169
1216
  );
1170
1217
  const coverArt = firstNonEmptyText(
@@ -1276,6 +1323,58 @@ function songAttrs(track, albumTitle = null, albumCoverArt = null, albumMetadata
1276
1323
  };
1277
1324
  }
1278
1325
 
1326
+ function trackArtistEntries(track) {
1327
+ const artistId = firstNonEmptyText(
1328
+ [track?.artistId, track?.guid, track?.grandparentRatingKey],
1329
+ '',
1330
+ );
1331
+ const artistName = firstNonEmptyText(
1332
+ [track?.originalTitle, track?.artist, track?.grandparentTitle],
1333
+ '',
1334
+ );
1335
+ const entry = buildArtistEntry(artistId, artistName);
1336
+ return entry ? [entry] : [];
1337
+ }
1338
+
1339
+ function trackAlbumArtistEntries(track, albumMetadata = null) {
1340
+ const albumArtistId = firstNonEmptyText(
1341
+ [albumMetadata?.artistId, albumMetadata?.parentRatingKey, track?.artistId, track?.grandparentRatingKey],
1342
+ '',
1343
+ );
1344
+ const albumArtistName = firstNonEmptyText(
1345
+ [albumMetadata?.artist, albumMetadata?.parentTitle, track?.artist, track?.grandparentTitle],
1346
+ '',
1347
+ );
1348
+ const entry = buildArtistEntry(albumArtistId, albumArtistName);
1349
+ return entry ? [entry] : [];
1350
+ }
1351
+
1352
+ function buildSongArtistChildren(track, albumMetadata = null) {
1353
+ const artistsNode = renderArtistListNode('artists', trackArtistEntries(track), { flatten: true });
1354
+ const albumArtistsNode = renderArtistListNode('albumArtists', trackAlbumArtistEntries(track, albumMetadata), {
1355
+ flatten: true,
1356
+ });
1357
+ return [artistsNode, albumArtistsNode].filter(Boolean);
1358
+ }
1359
+
1360
+ function songNode(track, albumTitle = null, albumCoverArt = null, albumMetadata = null) {
1361
+ const attrs = songAttrs(track, albumTitle, albumCoverArt, albumMetadata);
1362
+ const inner = buildSongArtistChildren(track, albumMetadata);
1363
+ if (inner.length === 0) {
1364
+ return emptyNode('song', attrs);
1365
+ }
1366
+ return node('song', attrs, inner);
1367
+ }
1368
+
1369
+ function songChildNode(track, albumTitle = null, albumCoverArt = null, albumMetadata = null, extraAttrs = {}) {
1370
+ const attrs = { ...songAttrs(track, albumTitle, albumCoverArt, albumMetadata), ...extraAttrs };
1371
+ const inner = buildSongArtistChildren(track, albumMetadata);
1372
+ if (inner.length === 0) {
1373
+ return emptyNode('child', attrs);
1374
+ }
1375
+ return node('child', attrs, inner);
1376
+ }
1377
+
1279
1378
  function sortTracksByDiscAndIndex(tracks) {
1280
1379
  return [...(Array.isArray(tracks) ? tracks : [])].sort((a, b) => {
1281
1380
  const discA = parsePositiveInt(a?.parentIndex ?? a?.discNumber, 1);
@@ -1820,39 +1919,39 @@ async function runPlexSearch({
1820
1919
  const [extraArtists, extraAlbums, extraTracks] = await Promise.all([
1821
1920
  missingArtist
1822
1921
  ? 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
- })
1922
+ baseUrl: plexState.baseUrl,
1923
+ plexToken: plexState.plexToken,
1924
+ sectionId: plexState.musicSectionId,
1925
+ type: 8,
1926
+ query,
1927
+ offset: 0,
1928
+ limit: artistWindow,
1929
+ signal,
1930
+ })
1832
1931
  : [],
1833
1932
  missingAlbum
1834
1933
  ? 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
- })
1934
+ baseUrl: plexState.baseUrl,
1935
+ plexToken: plexState.plexToken,
1936
+ sectionId: plexState.musicSectionId,
1937
+ type: 9,
1938
+ query,
1939
+ offset: 0,
1940
+ limit: albumWindow,
1941
+ signal,
1942
+ })
1844
1943
  : [],
1845
1944
  missingTrack
1846
1945
  ? 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
- })
1946
+ baseUrl: plexState.baseUrl,
1947
+ plexToken: plexState.plexToken,
1948
+ sectionId: plexState.musicSectionId,
1949
+ type: 10,
1950
+ query,
1951
+ offset: 0,
1952
+ limit: songWindow,
1953
+ signal,
1954
+ })
1856
1955
  : [],
1857
1956
  ]);
1858
1957
 
@@ -1868,6 +1967,10 @@ function isAbortError(error) {
1868
1967
  return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
1869
1968
  }
1870
1969
 
1970
+ function isNumericRatingKey(value) {
1971
+ return /^\d+$/.test(String(value || '').trim());
1972
+ }
1973
+
1871
1974
  function isUpstreamTerminationError(error) {
1872
1975
  if (!error) {
1873
1976
  return false;
@@ -1929,7 +2032,7 @@ async function fetchWithRetry({
1929
2032
 
1930
2033
  try {
1931
2034
  await response.body?.cancel?.();
1932
- } catch {}
2035
+ } catch { }
1933
2036
  } catch (error) {
1934
2037
  if (isAbortError(error)) {
1935
2038
  throw error;
@@ -2359,13 +2462,13 @@ function requiredPlexStateForSubsonic(reply, plexContext, tokenCipher) {
2359
2462
  try {
2360
2463
  const serverToken = decodePlexTokenOrThrow(tokenCipher, plexContext.server_token_enc);
2361
2464
  plexTokenCandidates.push(serverToken);
2362
- } catch {}
2465
+ } catch { }
2363
2466
  }
2364
2467
  if (plexContext.plex_token_enc) {
2365
2468
  try {
2366
2469
  const accountToken = decodePlexTokenOrThrow(tokenCipher, plexContext.plex_token_enc);
2367
2470
  plexTokenCandidates.push(accountToken);
2368
- } catch {}
2471
+ } catch { }
2369
2472
  }
2370
2473
 
2371
2474
  const plexToken = uniqueNonEmptyValues(plexTokenCandidates);
@@ -4174,7 +4277,7 @@ export async function buildServer(config = loadConfig()) {
4174
4277
  if (selectedServer.server_token_enc) {
4175
4278
  try {
4176
4279
  selectedServerToken = decodePlexTokenOrThrow(tokenCipher, selectedServer.server_token_enc);
4177
- } catch {}
4280
+ } catch { }
4178
4281
  }
4179
4282
 
4180
4283
  try {
@@ -4504,8 +4607,7 @@ export async function buildServer(config = loadConfig()) {
4504
4607
  playerId: session?.clientIdentifier || undefined,
4505
4608
  minutesAgo,
4506
4609
  });
4507
- })
4508
- .join('');
4610
+ });
4509
4611
 
4510
4612
  return sendSubsonicOk(reply, node('nowPlaying', {}, entries));
4511
4613
  } catch (error) {
@@ -4599,20 +4701,17 @@ export async function buildServer(config = loadConfig()) {
4599
4701
  coverArt: artist.ratingKey,
4600
4702
  ...subsonicRatingAttrs(artist),
4601
4703
  }),
4602
- )
4603
- .join('');
4704
+ );
4604
4705
 
4605
4706
  const starredAlbums = albums
4606
4707
  .filter((album) => isPlexLiked(album.userRating))
4607
- .map((album) => emptyNode('album', albumAttrs(album)))
4608
- .join('');
4708
+ .map((album) => albumNode('album', album, albumAttrs(album)));
4609
4709
 
4610
4710
  const starredSongs = tracks
4611
4711
  .filter((track) => isPlexLiked(track.userRating))
4612
- .map((track) => emptyNode('song', songAttrs(track)))
4613
- .join('');
4712
+ .map((track) => songNode(track));
4614
4713
 
4615
- return sendSubsonicOk(reply, node('starred', {}, `${starredArtists}${starredAlbums}${starredSongs}`));
4714
+ return sendSubsonicOk(reply, node('starred', {}, [...starredArtists, ...starredAlbums, ...starredSongs]));
4616
4715
  } catch (error) {
4617
4716
  request.log.error(error, 'Failed to load starred items');
4618
4717
  return sendSubsonicError(reply, 10, 'Failed to load starred items');
@@ -4648,20 +4747,17 @@ export async function buildServer(config = loadConfig()) {
4648
4747
  coverArt: artist.ratingKey,
4649
4748
  ...subsonicRatingAttrs(artist),
4650
4749
  }),
4651
- )
4652
- .join('');
4750
+ );
4653
4751
 
4654
4752
  const starredAlbums = albums
4655
4753
  .filter((album) => isPlexLiked(album.userRating))
4656
- .map((album) => emptyNode('album', albumId3Attrs(album)))
4657
- .join('');
4754
+ .map((album) => albumNode('album', album, albumId3Attrs(album)));
4658
4755
 
4659
4756
  const starredSongs = tracks
4660
4757
  .filter((track) => isPlexLiked(track.userRating))
4661
- .map((track) => emptyNode('song', songAttrs(track)))
4662
- .join('');
4758
+ .map((track) => songNode(track));
4663
4759
 
4664
- return sendSubsonicOk(reply, node('starred2', {}, `${starredArtists}${starredAlbums}${starredSongs}`));
4760
+ return sendSubsonicOk(reply, node('starred2', {}, [...starredArtists, ...starredAlbums, ...starredSongs]));
4665
4761
  } catch (error) {
4666
4762
  request.log.error(error, 'Failed to load starred items');
4667
4763
  return sendSubsonicError(reply, 10, 'Failed to load starred items');
@@ -4712,7 +4808,7 @@ export async function buildServer(config = loadConfig()) {
4712
4808
  }
4713
4809
  }
4714
4810
 
4715
- const genresXml = [...counts.values()]
4811
+ const genreNodes = [...counts.values()]
4716
4812
  .sort((a, b) => a.name.localeCompare(b.name))
4717
4813
  .map((genre) =>
4718
4814
  node(
@@ -4723,10 +4819,9 @@ export async function buildServer(config = loadConfig()) {
4723
4819
  },
4724
4820
  genre.name,
4725
4821
  ),
4726
- )
4727
- .join('');
4822
+ );
4728
4823
 
4729
- return sendSubsonicOk(reply, node('genres', {}, genresXml));
4824
+ return sendSubsonicOk(reply, node('genres', {}, genreNodes));
4730
4825
  } catch (error) {
4731
4826
  request.log.error(error, 'Failed to load genres');
4732
4827
  return sendSubsonicError(reply, 10, 'Failed to load genres');
@@ -4774,8 +4869,8 @@ export async function buildServer(config = loadConfig()) {
4774
4869
  const sortedSongs = sortTracksForLibraryBrowse(matchedSongs);
4775
4870
  const page = takePage(sortedSongs, offset, count)
4776
4871
  .map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
4777
- const songXml = page.map((track) => emptyNode('song', songAttrs(track))).join('');
4778
- return sendSubsonicOk(reply, node('songsByGenre', {}, songXml));
4872
+ const songs = page.map((track) => songNode(track));
4873
+ return sendSubsonicOk(reply, node('songsByGenre', {}, songs));
4779
4874
  } catch (error) {
4780
4875
  request.log.error(error, 'Failed to load songs by genre');
4781
4876
  return sendSubsonicError(reply, 10, 'Failed to load songs by genre');
@@ -4801,8 +4896,8 @@ export async function buildServer(config = loadConfig()) {
4801
4896
  const allTracks = await getCachedLibraryTracks({ accountId: account.id, plexState, request });
4802
4897
 
4803
4898
  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));
4899
+ const songs = randomTracks.map((track) => songNode(track));
4900
+ return sendSubsonicOk(reply, node('randomSongs', {}, songs));
4806
4901
  } catch (error) {
4807
4902
  request.log.error(error, 'Failed to load random songs');
4808
4903
  return sendSubsonicError(reply, 10, 'Failed to load random songs');
@@ -4845,8 +4940,8 @@ export async function buildServer(config = loadConfig()) {
4845
4940
  applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
4846
4941
 
4847
4942
  const topTracks = tracks.slice(0, size);
4848
- const songXml = topTracks.map((track) => emptyNode('song', songAttrs(track))).join('');
4849
- return sendSubsonicOk(reply, node('topSongs', {}, songXml));
4943
+ const songs = topTracks.map((track) => songNode(track));
4944
+ return sendSubsonicOk(reply, node('topSongs', {}, songs));
4850
4945
  } catch (error) {
4851
4946
  request.log.error(error, 'Failed to load top songs');
4852
4947
  return sendSubsonicError(reply, 10, 'Failed to load top songs');
@@ -4966,8 +5061,8 @@ export async function buildServer(config = loadConfig()) {
4966
5061
  }
4967
5062
  applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
4968
5063
 
4969
- const songXml = tracks.map((track) => emptyNode('song', songAttrs(track))).join('');
4970
- return sendSubsonicOk(reply, node('similarSongs', {}, songXml));
5064
+ const songs = tracks.map((track) => songNode(track));
5065
+ return sendSubsonicOk(reply, node('similarSongs', {}, songs));
4971
5066
  } catch (error) {
4972
5067
  request.log.error(error, 'Failed to load similar songs');
4973
5068
  return sendSubsonicError(reply, 10, 'Failed to load similar songs');
@@ -5010,8 +5105,8 @@ export async function buildServer(config = loadConfig()) {
5010
5105
  }
5011
5106
  applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
5012
5107
 
5013
- const songXml = tracks.map((track) => emptyNode('song', songAttrs(track))).join('');
5014
- return sendSubsonicOk(reply, node('similarSongs2', {}, songXml));
5108
+ const songs = tracks.map((track) => songNode(track));
5109
+ return sendSubsonicOk(reply, node('similarSongs2', {}, songs));
5015
5110
  } catch (error) {
5016
5111
  request.log.error(error, 'Failed to load similar songs2');
5017
5112
  return sendSubsonicError(reply, 10, 'Failed to load similar songs');
@@ -5143,11 +5238,11 @@ export async function buildServer(config = loadConfig()) {
5143
5238
 
5144
5239
  const lyricCandidates = matchedTrackId
5145
5240
  ? await fetchPlexTrackLyricsCandidates({
5146
- baseUrl: plexState.baseUrl,
5147
- plexToken: plexState.plexToken,
5148
- trackId: matchedTrackId,
5149
- signal: searchScope.signal,
5150
- })
5241
+ baseUrl: plexState.baseUrl,
5242
+ plexToken: plexState.plexToken,
5243
+ trackId: matchedTrackId,
5244
+ signal: searchScope.signal,
5245
+ })
5151
5246
  : [];
5152
5247
 
5153
5248
  const plainLyrics = buildPlainLyricsFromStructuredLyrics(
@@ -5232,9 +5327,9 @@ export async function buildServer(config = loadConfig()) {
5232
5327
  });
5233
5328
 
5234
5329
  const structuredLyrics = extractStructuredLyricsFromTrack(track, lyricCandidates);
5235
- const structuredLyricsXml = structuredLyrics
5330
+ const structuredLyricsNodes = structuredLyrics
5236
5331
  .map((lyrics) => {
5237
- const lineXml = lyrics.lines
5332
+ const lineNodes = lyrics.lines
5238
5333
  .map((line) =>
5239
5334
  node(
5240
5335
  'line',
@@ -5243,8 +5338,7 @@ export async function buildServer(config = loadConfig()) {
5243
5338
  },
5244
5339
  line.value,
5245
5340
  ),
5246
- )
5247
- .join('');
5341
+ );
5248
5342
 
5249
5343
  return node(
5250
5344
  'structuredLyrics',
@@ -5255,12 +5349,11 @@ export async function buildServer(config = loadConfig()) {
5255
5349
  synced: lyrics.synced,
5256
5350
  offset: lyrics.offset,
5257
5351
  },
5258
- lineXml,
5352
+ lineNodes,
5259
5353
  );
5260
- })
5261
- .join('');
5354
+ });
5262
5355
 
5263
- return sendSubsonicOk(reply, node('lyricsList', {}, structuredLyricsXml));
5356
+ return sendSubsonicOk(reply, node('lyricsList', {}, structuredLyricsNodes));
5264
5357
  } catch (error) {
5265
5358
  if (isAbortError(error)) {
5266
5359
  const reason = String(lyricsScope.reason() || '');
@@ -5364,7 +5457,7 @@ export async function buildServer(config = loadConfig()) {
5364
5457
  );
5365
5458
  }
5366
5459
 
5367
- const artistXml = matchedArtists
5460
+ const artistNodes = matchedArtists
5368
5461
  .map((artist) =>
5369
5462
  emptyNode('artist', {
5370
5463
  id: artist.ratingKey,
@@ -5373,16 +5466,13 @@ export async function buildServer(config = loadConfig()) {
5373
5466
  coverArt: artist.ratingKey,
5374
5467
  ...subsonicRatingAttrs(artist),
5375
5468
  }),
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}`));
5469
+ );
5470
+ const albumNodes = matchedAlbums
5471
+ .map((album) => albumNode('album', album, albumId3Attrs(album)));
5472
+ const songNodes = matchedTracks
5473
+ .map((track) => songNode(track));
5474
+
5475
+ return sendSubsonicOk(reply, node('searchResult3', {}, [...artistNodes, ...albumNodes, ...songNodes]));
5386
5476
  } catch (error) {
5387
5477
  if (isAbortError(error)) {
5388
5478
  const reason = String(searchScope.reason() || '');
@@ -5462,7 +5552,7 @@ export async function buildServer(config = loadConfig()) {
5462
5552
  songCount,
5463
5553
  );
5464
5554
 
5465
- const artistXml = matchedArtists
5555
+ const artistNodes = matchedArtists
5466
5556
  .map((artist) =>
5467
5557
  emptyNode('artist', {
5468
5558
  id: artist.ratingKey,
@@ -5470,16 +5560,13 @@ export async function buildServer(config = loadConfig()) {
5470
5560
  coverArt: artist.ratingKey,
5471
5561
  ...subsonicRatingAttrs(artist),
5472
5562
  }),
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}`));
5563
+ );
5564
+ const albumNodes = matchedAlbums
5565
+ .map((album) => albumNode('album', album, albumAttrs(album)));
5566
+ const songNodes = matchedTracks
5567
+ .map((track) => songNode(track));
5568
+
5569
+ return sendSubsonicOk(reply, node('searchResult2', {}, [...artistNodes, ...albumNodes, ...songNodes]));
5483
5570
  } catch (error) {
5484
5571
  if (isAbortError(error)) {
5485
5572
  const reason = String(searchScope.reason() || '');
@@ -5559,7 +5646,7 @@ export async function buildServer(config = loadConfig()) {
5559
5646
  songCount,
5560
5647
  );
5561
5648
 
5562
- const artistXml = matchedArtists
5649
+ const artistNodes = matchedArtists
5563
5650
  .map((artist) =>
5564
5651
  emptyNode('artist', {
5565
5652
  id: artist.ratingKey,
@@ -5567,12 +5654,10 @@ export async function buildServer(config = loadConfig()) {
5567
5654
  coverArt: artist.ratingKey,
5568
5655
  ...subsonicRatingAttrs(artist),
5569
5656
  }),
5570
- )
5571
- .join('');
5572
- const albumXml = matchedAlbums
5573
- .map((album) => emptyNode('album', albumAttrs(album)))
5574
- .join('');
5575
- const matchXml = matchedTracks
5657
+ );
5658
+ const albumNodes = matchedAlbums
5659
+ .map((album) => albumNode('album', album, albumAttrs(album)));
5660
+ const matchNodes = matchedTracks
5576
5661
  .map((track) =>
5577
5662
  emptyNode('match', {
5578
5663
  id: track.ratingKey,
@@ -5580,10 +5665,9 @@ export async function buildServer(config = loadConfig()) {
5580
5665
  album: track.parentTitle,
5581
5666
  artist: track.grandparentTitle,
5582
5667
  }),
5583
- )
5584
- .join('');
5668
+ );
5585
5669
 
5586
- return sendSubsonicOk(reply, node('searchResult', {}, `${artistXml}${albumXml}${matchXml}`));
5670
+ return sendSubsonicOk(reply, node('searchResult', {}, [...artistNodes, ...albumNodes, ...matchNodes]));
5587
5671
  } catch (error) {
5588
5672
  if (isAbortError(error)) {
5589
5673
  const reason = String(searchScope.reason() || '');
@@ -5656,8 +5740,7 @@ export async function buildServer(config = loadConfig()) {
5656
5740
  });
5657
5741
 
5658
5742
  const entries = tracks
5659
- .map((track) => emptyNode('entry', songAttrs(track)))
5660
- .join('');
5743
+ .map((track) => emptyNode('entry', songAttrs(track)));
5661
5744
  const changedIso = new Date(Number(queueState.updatedAt || Date.now())).toISOString();
5662
5745
 
5663
5746
  return sendSubsonicOk(
@@ -6196,11 +6279,10 @@ export async function buildServer(config = loadConfig()) {
6196
6279
  });
6197
6280
 
6198
6281
  const nowIso = new Date().toISOString();
6199
- const playlistXml = playlists
6200
- .map((playlist) => emptyNode('playlist', playlistAttrs(playlist, account.username, nowIso)))
6201
- .join('');
6282
+ const playlistNodes = playlists
6283
+ .map((playlist) => emptyNode('playlist', playlistAttrs(playlist, account.username, nowIso)));
6202
6284
 
6203
- return sendSubsonicOk(reply, node('playlists', {}, playlistXml));
6285
+ return sendSubsonicOk(reply, node('playlists', {}, playlistNodes));
6204
6286
  } catch (error) {
6205
6287
  request.log.error(error, 'Failed to load playlists');
6206
6288
  return sendSubsonicError(reply, 10, 'Failed to load playlists');
@@ -6247,8 +6329,7 @@ export async function buildServer(config = loadConfig()) {
6247
6329
  .map((track) => {
6248
6330
  const attrs = songAttrs(track, track.parentTitle || undefined, track.parentRatingKey || undefined);
6249
6331
  return emptyNode('entry', attrs);
6250
- })
6251
- .join('');
6332
+ });
6252
6333
 
6253
6334
  return sendSubsonicOk(reply, node('playlist', playlistAttrs(playlist, account.username, nowIso), entries));
6254
6335
  } catch (error) {
@@ -6421,7 +6502,7 @@ export async function buildServer(config = loadConfig()) {
6421
6502
  try {
6422
6503
  const artists = await getCachedLibraryArtists({ accountId: account.id, plexState, request });
6423
6504
 
6424
- const indexes = groupArtistsForSubsonic(artists).join('');
6505
+ const indexes = groupArtistsForSubsonic(artists);
6425
6506
  return sendSubsonicOk(reply, node('artists', { ignoredArticles: 'The El La Los Las Le Les' }, indexes));
6426
6507
  } catch (error) {
6427
6508
  request.log.error(error, 'Failed to load artists from Plex');
@@ -6465,7 +6546,7 @@ export async function buildServer(config = loadConfig()) {
6465
6546
  })
6466
6547
  .filter(Boolean);
6467
6548
 
6468
- const indexes = groupNamedEntriesForSubsonic(folderEntries).join('');
6549
+ const indexes = groupNamedEntriesForSubsonic(folderEntries);
6469
6550
  return sendSubsonicOk(
6470
6551
  reply,
6471
6552
  node(
@@ -6494,6 +6575,10 @@ export async function buildServer(config = loadConfig()) {
6494
6575
  return sendSubsonicError(reply, 70, 'Missing artist id');
6495
6576
  }
6496
6577
 
6578
+ if (!isNumericRatingKey(artistId)) {
6579
+ return sendSubsonicOk(reply, node('artist', {}, ''));
6580
+ }
6581
+
6497
6582
  const context = repo.getAccountPlexContext(account.id);
6498
6583
  const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
6499
6584
  if (!plexState) {
@@ -6549,8 +6634,15 @@ export async function buildServer(config = loadConfig()) {
6549
6634
  }
6550
6635
 
6551
6636
  const albumXml = finalAlbums
6552
- .map((album) => emptyNode('album', albumId3Attrs(album, artist.ratingKey, artist.title)))
6553
- .join('');
6637
+ .map((album) =>
6638
+ albumNode(
6639
+ 'album',
6640
+ album,
6641
+ albumId3Attrs(album, artist.ratingKey, artist.title),
6642
+ artist.ratingKey,
6643
+ artist.title,
6644
+ ),
6645
+ );
6554
6646
 
6555
6647
  return sendSubsonicOk(
6556
6648
  reply,
@@ -6583,6 +6675,10 @@ export async function buildServer(config = loadConfig()) {
6583
6675
  return sendSubsonicError(reply, 70, 'Missing artist id');
6584
6676
  }
6585
6677
 
6678
+ if (!isNumericRatingKey(artistId)) {
6679
+ return sendSubsonicOk(reply, node('artistInfo'));
6680
+ }
6681
+
6586
6682
  const context = repo.getAccountPlexContext(account.id);
6587
6683
  const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
6588
6684
  if (!plexState) {
@@ -6622,11 +6718,9 @@ export async function buildServer(config = loadConfig()) {
6622
6718
  const biography = artistBioFromPlex(artist);
6623
6719
  const musicBrainzId = extractMusicBrainzArtistId(artist);
6624
6720
  const children = [
6625
- biography ? node('biography', {}, biography) : '',
6626
- musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : '',
6627
- ]
6628
- .filter(Boolean)
6629
- .join('');
6721
+ biography ? node('biography', {}, biography) : null,
6722
+ musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : null,
6723
+ ].filter(Boolean);
6630
6724
 
6631
6725
  return sendSubsonicOk(reply, node('artistInfo', {}, children));
6632
6726
  } catch (error) {
@@ -6646,6 +6740,10 @@ export async function buildServer(config = loadConfig()) {
6646
6740
  return sendSubsonicError(reply, 70, 'Missing artist id');
6647
6741
  }
6648
6742
 
6743
+ if (!isNumericRatingKey(artistId)) {
6744
+ return sendSubsonicOk(reply, node('artistInfo2'));
6745
+ }
6746
+
6649
6747
  const context = repo.getAccountPlexContext(account.id);
6650
6748
  const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
6651
6749
  if (!plexState) {
@@ -6685,11 +6783,9 @@ export async function buildServer(config = loadConfig()) {
6685
6783
  const biography = artistBioFromPlex(artist);
6686
6784
  const musicBrainzId = extractMusicBrainzArtistId(artist);
6687
6785
  const children = [
6688
- biography ? node('biography', {}, biography) : '',
6689
- musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : '',
6690
- ]
6691
- .filter(Boolean)
6692
- .join('');
6786
+ biography ? node('biography', {}, biography) : null,
6787
+ musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : null,
6788
+ ].filter(Boolean);
6693
6789
 
6694
6790
  return sendSubsonicOk(reply, node('artistInfo2', {}, children));
6695
6791
  } catch (error) {
@@ -6709,6 +6805,10 @@ export async function buildServer(config = loadConfig()) {
6709
6805
  return sendSubsonicError(reply, 70, 'Missing album id');
6710
6806
  }
6711
6807
 
6808
+ if (!isNumericRatingKey(albumId)) {
6809
+ return sendSubsonicOk(reply, node('album'));
6810
+ }
6811
+
6712
6812
  const context = repo.getAccountPlexContext(account.id);
6713
6813
  const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
6714
6814
  if (!plexState) {
@@ -6742,20 +6842,22 @@ export async function buildServer(config = loadConfig()) {
6742
6842
 
6743
6843
  const totalDuration = sortedTracks.reduce((sum, track) => sum + durationSeconds(track.duration), 0);
6744
6844
 
6745
- const songXml = sortedTracks
6746
- .map((track) => emptyNode('song', songAttrs(track, album.title, album.ratingKey, album)))
6747
- .join('');
6845
+ const songNodes = sortedTracks
6846
+ .map((track) => songNode(track, album.title, album.ratingKey, album));
6748
6847
 
6749
6848
  return sendSubsonicOk(
6750
6849
  reply,
6751
- node(
6850
+ albumNode(
6752
6851
  'album',
6852
+ album,
6753
6853
  {
6754
6854
  ...albumId3Attrs(album, album.parentRatingKey || null, album.parentTitle || null),
6755
6855
  songCount: sortedTracks.length,
6756
6856
  duration: totalDuration,
6757
6857
  },
6758
- songXml,
6858
+ album.parentRatingKey || null,
6859
+ album.parentTitle || null,
6860
+ songNodes,
6759
6861
  ),
6760
6862
  );
6761
6863
  } catch (error) {
@@ -6804,15 +6906,20 @@ export async function buildServer(config = loadConfig()) {
6804
6906
  const children = folderResult.items
6805
6907
  .map((item) => {
6806
6908
  if (isLikelyPlexTrack(item)) {
6807
- return emptyNode('child', {
6808
- ...songAttrs(item, item.parentTitle || null, item.parentRatingKey || null),
6809
- isDir: false,
6810
- parent: currentDirectoryId,
6811
- });
6909
+ return songChildNode(
6910
+ item,
6911
+ item.parentTitle || null,
6912
+ item.parentRatingKey || null,
6913
+ null,
6914
+ {
6915
+ isDir: false,
6916
+ parent: currentDirectoryId,
6917
+ },
6918
+ );
6812
6919
  }
6813
6920
 
6814
6921
  if (isLikelyPlexAlbum(item)) {
6815
- return emptyNode('child', {
6922
+ return albumNode('child', item, {
6816
6923
  ...albumAttrs(item),
6817
6924
  isDir: true,
6818
6925
  parent: currentDirectoryId,
@@ -6837,8 +6944,7 @@ export async function buildServer(config = loadConfig()) {
6837
6944
  name: title,
6838
6945
  });
6839
6946
  })
6840
- .filter(Boolean)
6841
- .join('');
6947
+ .filter(Boolean);
6842
6948
 
6843
6949
  const container = folderResult.container || {};
6844
6950
  const directoryName = isRootFolder
@@ -6892,8 +6998,15 @@ export async function buildServer(config = loadConfig()) {
6892
6998
  }
6893
6999
 
6894
7000
  const children = finalAlbums
6895
- .map((album) => emptyNode('child', albumAttrs(album, artist.ratingKey, artist.title)))
6896
- .join('');
7001
+ .map((album) =>
7002
+ albumNode(
7003
+ 'child',
7004
+ album,
7005
+ albumAttrs(album, artist.ratingKey, artist.title),
7006
+ artist.ratingKey,
7007
+ artist.title,
7008
+ ),
7009
+ );
6897
7010
 
6898
7011
  return sendSubsonicOk(
6899
7012
  reply,
@@ -6919,8 +7032,15 @@ export async function buildServer(config = loadConfig()) {
6919
7032
  if (fallback?.artist) {
6920
7033
  const fallbackAlbums = fallback.albums || [];
6921
7034
  const children = fallbackAlbums
6922
- .map((album) => emptyNode('child', albumAttrs(album, fallback.artist.ratingKey, fallback.artist.title)))
6923
- .join('');
7035
+ .map((album) =>
7036
+ albumNode(
7037
+ 'child',
7038
+ album,
7039
+ albumAttrs(album, fallback.artist.ratingKey, fallback.artist.title),
7040
+ fallback.artist.ratingKey,
7041
+ fallback.artist.title,
7042
+ ),
7043
+ );
6924
7044
 
6925
7045
  return sendSubsonicOk(
6926
7046
  reply,
@@ -6960,13 +7080,11 @@ export async function buildServer(config = loadConfig()) {
6960
7080
 
6961
7081
  const children = sortedTracks
6962
7082
  .map((track) =>
6963
- emptyNode('child', {
6964
- ...songAttrs(track, album.title, album.ratingKey, album),
7083
+ songChildNode(track, album.title, album.ratingKey, album, {
6965
7084
  isDir: false,
6966
7085
  parent: album.ratingKey,
6967
7086
  }),
6968
- )
6969
- .join('');
7087
+ );
6970
7088
 
6971
7089
  return sendSubsonicOk(
6972
7090
  reply,
@@ -7041,13 +7159,16 @@ export async function buildServer(config = loadConfig()) {
7041
7159
  });
7042
7160
  const page = sliceAlbumPage(filtered, offset, size);
7043
7161
  reply.header('x-total-count', String(filtered.length));
7044
- const albumXml = page
7162
+ const albumNodes = page
7045
7163
  .map((album) =>
7046
- emptyNode('album', containerName === 'albumList2' ? albumId3Attrs(album) : albumAttrs(album)),
7047
- )
7048
- .join('');
7164
+ albumNode(
7165
+ 'album',
7166
+ album,
7167
+ containerName === 'albumList2' ? albumId3Attrs(album) : albumAttrs(album),
7168
+ ),
7169
+ );
7049
7170
 
7050
- return sendSubsonicOk(reply, node(containerName, {}, albumXml));
7171
+ return sendSubsonicOk(reply, node(containerName, {}, albumNodes));
7051
7172
  } catch (error) {
7052
7173
  request.log.error(error, 'Failed to load album list');
7053
7174
  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']),
@@ -304,20 +263,24 @@ function nodeToJson(node) {
304
263
  out[child.name] = value;
305
264
  }
306
265
 
307
- const arrayChildren = ARRAY_CHILDREN_BY_PARENT[node.name];
308
- if (arrayChildren) {
309
- for (const childName of arrayChildren) {
310
- if (!Object.prototype.hasOwnProperty.call(out, childName)) {
311
- out[childName] = [];
312
- }
313
- }
314
- }
315
-
316
266
  const keys = Object.keys(out);
317
267
  if (keys.length === 1 && keys[0] === 'value') {
318
268
  return out.value;
319
269
  }
320
270
 
271
+ if (node.name === 'artists' || node.name === 'albumArtists') {
272
+ if (node.attrs?.flatten === 'true') {
273
+ const artistValue = out.artist;
274
+ if (Array.isArray(artistValue)) {
275
+ return artistValue;
276
+ }
277
+ if (artistValue != null) {
278
+ return [artistValue];
279
+ }
280
+ return [];
281
+ }
282
+ }
283
+
321
284
  if (node.name === 'openSubsonicExtensions') {
322
285
  const extensions = out.openSubsonicExtension;
323
286
  if (Array.isArray(extensions)) {
@@ -347,23 +310,23 @@ function subsonicRoot(status, children = []) {
347
310
  }
348
311
 
349
312
  export function emptyNode(name, attrs = {}) {
350
- return storeNode({
313
+ return {
351
314
  kind: 'node',
352
315
  name,
353
316
  attrs,
354
317
  children: [],
355
318
  selfClosing: true,
356
- });
319
+ };
357
320
  }
358
321
 
359
- export function node(name, attrs = {}, inner = '') {
360
- return storeNode({
322
+ export function node(name, attrs = {}, inner = null) {
323
+ return {
361
324
  kind: 'node',
362
325
  name,
363
326
  attrs,
364
327
  children: normalizeChildren(inner),
365
328
  selfClosing: false,
366
- });
329
+ };
367
330
  }
368
331
 
369
332
  export function okResponse(inner = '') {
@@ -410,16 +373,3 @@ export function failedResponseJson(code, message) {
410
373
  return { 'subsonic-response': json };
411
374
  }
412
375
 
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
- }