plexsonic 0.1.4 → 0.1.6

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.4",
3
+ "version": "0.1.6",
4
4
  "description": "PlexMusic to OpenSubsonic bridge",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
package/src/server.js CHANGED
@@ -868,6 +868,111 @@ function isPlexLiked(value) {
868
868
  return normalized != null && normalized >= 2 && normalized % 2 === 0;
869
869
  }
870
870
 
871
+ function normalizePlainText(value) {
872
+ return String(value || '')
873
+ .replace(/<br\s*\/?>/gi, '\n')
874
+ .replace(/<\/p>/gi, '\n')
875
+ .replace(/<[^>]*>/g, '')
876
+ .replace(/\r\n/g, '\n')
877
+ .replace(/\n{3,}/g, '\n\n')
878
+ .trim();
879
+ }
880
+
881
+ function asArray(value) {
882
+ if (Array.isArray(value)) {
883
+ return value;
884
+ }
885
+ if (value == null) {
886
+ return [];
887
+ }
888
+ return [value];
889
+ }
890
+
891
+ function plexGuidIds(item) {
892
+ const candidates = [];
893
+
894
+ for (const guid of asArray(item?.Guid)) {
895
+ if (typeof guid === 'string') {
896
+ candidates.push(guid);
897
+ continue;
898
+ }
899
+ if (guid && typeof guid === 'object' && typeof guid.id === 'string') {
900
+ candidates.push(guid.id);
901
+ }
902
+ }
903
+
904
+ for (const raw of [item?.guid, item?.guids]) {
905
+ if (typeof raw === 'string') {
906
+ candidates.push(raw);
907
+ } else if (Array.isArray(raw)) {
908
+ for (const entry of raw) {
909
+ if (typeof entry === 'string') {
910
+ candidates.push(entry);
911
+ } else if (entry && typeof entry === 'object' && typeof entry.id === 'string') {
912
+ candidates.push(entry.id);
913
+ }
914
+ }
915
+ }
916
+ }
917
+
918
+ return uniqueNonEmptyValues(candidates);
919
+ }
920
+
921
+ function extractMusicBrainzArtistId(item) {
922
+ const guidIds = plexGuidIds(item);
923
+ const uuidPattern = /([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/i;
924
+
925
+ for (const guid of guidIds) {
926
+ const lower = safeLower(guid);
927
+
928
+ if (lower.startsWith('mbid://')) {
929
+ const id = guid.slice('mbid://'.length).split(/[/?#]/, 1)[0].trim();
930
+ if (id) {
931
+ return id;
932
+ }
933
+ }
934
+
935
+ if (lower.startsWith('musicbrainz://')) {
936
+ const id = guid
937
+ .slice('musicbrainz://'.length)
938
+ .replace(/^artist\//i, '')
939
+ .split(/[?#]/, 1)[0]
940
+ .replace(/^\/+/, '')
941
+ .trim();
942
+ if (id) {
943
+ return id;
944
+ }
945
+ }
946
+
947
+ if (lower.includes('musicbrainz.org/artist/')) {
948
+ const match = guid.match(/musicbrainz\.org\/artist\/([^/?#]+)/i);
949
+ if (match?.[1]) {
950
+ return match[1];
951
+ }
952
+ }
953
+
954
+ if (lower.includes('musicbrainz')) {
955
+ const uuid = guid.match(uuidPattern)?.[1];
956
+ if (uuid) {
957
+ return uuid;
958
+ }
959
+ }
960
+ }
961
+
962
+ return '';
963
+ }
964
+
965
+ function artistBioFromPlex(item) {
966
+ return firstNonEmptyText(
967
+ [
968
+ normalizePlainText(item?.summary),
969
+ normalizePlainText(item?.tagline),
970
+ normalizePlainText(item?.description),
971
+ ],
972
+ '',
973
+ );
974
+ }
975
+
871
976
  function subsonicRatingToPlexRating(value, { liked = false } = {}) {
872
977
  const rating = Number.parseInt(String(value ?? ''), 10);
873
978
  if (!Number.isFinite(rating) || rating <= 0) {
@@ -1694,6 +1799,64 @@ function isAbortError(error) {
1694
1799
  return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
1695
1800
  }
1696
1801
 
1802
+ const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
1803
+
1804
+ function waitMs(ms) {
1805
+ return new Promise((resolve) => {
1806
+ setTimeout(resolve, ms);
1807
+ });
1808
+ }
1809
+
1810
+ async function fetchWithRetry({
1811
+ url,
1812
+ options = {},
1813
+ request = null,
1814
+ context = 'upstream request',
1815
+ maxAttempts = 3,
1816
+ baseDelayMs = 200,
1817
+ }) {
1818
+ let lastError = null;
1819
+
1820
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1821
+ try {
1822
+ const response = await fetch(url, options);
1823
+ if (!RETRYABLE_UPSTREAM_STATUSES.has(response.status) || attempt >= maxAttempts) {
1824
+ return response;
1825
+ }
1826
+
1827
+ request?.log?.warn(
1828
+ { context, status: response.status, attempt, maxAttempts },
1829
+ 'Transient upstream failure, retrying',
1830
+ );
1831
+
1832
+ try {
1833
+ await response.body?.cancel?.();
1834
+ } catch {}
1835
+ } catch (error) {
1836
+ if (isAbortError(error)) {
1837
+ throw error;
1838
+ }
1839
+ lastError = error;
1840
+ if (attempt >= maxAttempts) {
1841
+ throw error;
1842
+ }
1843
+
1844
+ request?.log?.warn(
1845
+ { context, attempt, maxAttempts, message: error?.message || String(error) },
1846
+ 'Transient upstream error, retrying',
1847
+ );
1848
+ }
1849
+
1850
+ await waitMs(baseDelayMs * attempt);
1851
+ }
1852
+
1853
+ if (lastError) {
1854
+ throw lastError;
1855
+ }
1856
+
1857
+ throw new Error(`${context} failed after retries`);
1858
+ }
1859
+
1697
1860
  function isPlexNotFoundError(error) {
1698
1861
  return String(error?.message || '').includes('(404)');
1699
1862
  }
@@ -2120,6 +2283,7 @@ function requiredPlexStateForSubsonic(reply, plexContext, tokenCipher) {
2120
2283
  baseUrl: plexContext.server_base_url,
2121
2284
  machineId: plexContext.machine_id,
2122
2285
  musicSectionId: plexContext.music_section_id,
2286
+ musicSectionName: plexContext.music_section_name || null,
2123
2287
  serverName: plexContext.server_name || 'Plex Music',
2124
2288
  };
2125
2289
  }
@@ -2287,11 +2451,13 @@ export async function buildServer(config = loadConfig()) {
2287
2451
  });
2288
2452
 
2289
2453
  const playbackSessions = new Map();
2454
+ const savedPlayQueues = new Map();
2290
2455
  const PLAYBACK_RECONCILE_INTERVAL_MS = 15000;
2291
2456
  const PLAYBACK_IDLE_TIMEOUT_MS = 120000;
2292
2457
  const STREAM_DISCONNECT_STOP_DELAY_MS = 4000;
2293
2458
  const PLAYBACK_MAX_DISCONNECT_WAIT_MS = 30 * 60 * 1000;
2294
2459
  const STREAM_PROGRESS_HEARTBEAT_MS = 10000;
2460
+ const PLAY_QUEUE_IDLE_TTL_MS = 6 * 60 * 60 * 1000;
2295
2461
  const activeSearchRequests = new Map();
2296
2462
  const searchBrowseCache = new Map();
2297
2463
  const SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS = 15000;
@@ -2891,6 +3057,90 @@ export async function buildServer(config = loadConfig()) {
2891
3057
  });
2892
3058
  }
2893
3059
 
3060
+ function pruneSavedPlayQueues(now = Date.now()) {
3061
+ for (const [key, value] of savedPlayQueues.entries()) {
3062
+ if (!value || (now - Number(value.updatedAt || 0)) > PLAY_QUEUE_IDLE_TTL_MS) {
3063
+ savedPlayQueues.delete(key);
3064
+ }
3065
+ }
3066
+ }
3067
+
3068
+ function playQueueClientKey(request) {
3069
+ const rawClient =
3070
+ getRequestParam(request, 'c') ||
3071
+ String(request.headers?.['user-agent'] || '').trim() ||
3072
+ 'subsonic-client';
3073
+ return safeLower(rawClient).slice(0, 128) || 'subsonic-client';
3074
+ }
3075
+
3076
+ function playQueueStorageKey(accountId, request) {
3077
+ return `${accountId}:${playQueueClientKey(request)}`;
3078
+ }
3079
+
3080
+ function requestedPlayQueueItemIds(request) {
3081
+ const ids = getRequestParamValues(request, 'id');
3082
+ if (ids.length > 0) {
3083
+ return ids;
3084
+ }
3085
+ return getRequestParamValues(request, 'songId');
3086
+ }
3087
+
3088
+ async function resolveTracksByIdOrder({ accountId, plexState, request, ids }) {
3089
+ const orderedIds = (Array.isArray(ids) ? ids : [])
3090
+ .map((id) => String(id || '').trim())
3091
+ .filter(Boolean);
3092
+ if (orderedIds.length === 0) {
3093
+ return [];
3094
+ }
3095
+
3096
+ const tracks = await getCachedLibraryTracks({ accountId, plexState, request });
3097
+ const cachedById = new Map();
3098
+ for (const track of tracks) {
3099
+ const ratingKey = String(track?.ratingKey || '').trim();
3100
+ if (ratingKey && !cachedById.has(ratingKey)) {
3101
+ cachedById.set(ratingKey, track);
3102
+ }
3103
+ }
3104
+
3105
+ const missingIds = [];
3106
+ for (const id of orderedIds) {
3107
+ if (!cachedById.has(id)) {
3108
+ missingIds.push(id);
3109
+ }
3110
+ }
3111
+
3112
+ if (missingIds.length > 0) {
3113
+ const fetched = await Promise.all(
3114
+ missingIds.map(async (trackId) => {
3115
+ try {
3116
+ const track = await getTrack({
3117
+ baseUrl: plexState.baseUrl,
3118
+ plexToken: plexState.plexToken,
3119
+ trackId,
3120
+ });
3121
+ return track || null;
3122
+ } catch {
3123
+ return null;
3124
+ }
3125
+ }),
3126
+ );
3127
+
3128
+ const nonNullFetched = fetched.filter(Boolean);
3129
+ applyCachedRatingOverridesForAccount({ accountId, plexState, items: nonNullFetched });
3130
+
3131
+ for (const track of nonNullFetched) {
3132
+ const ratingKey = String(track?.ratingKey || '').trim();
3133
+ if (ratingKey && !cachedById.has(ratingKey)) {
3134
+ cachedById.set(ratingKey, track);
3135
+ }
3136
+ }
3137
+ }
3138
+
3139
+ return orderedIds
3140
+ .map((id) => cachedById.get(id))
3141
+ .filter(Boolean);
3142
+ }
3143
+
2894
3144
  function applyCachedRatingOverridesForAccount({ accountId, plexState, items }) {
2895
3145
  if (!Array.isArray(items) || items.length === 0) {
2896
3146
  return 0;
@@ -3889,10 +4139,17 @@ export async function buildServer(config = loadConfig()) {
3889
4139
  return;
3890
4140
  }
3891
4141
 
4142
+ const rawSectionId = String(plexState.musicSectionId || '').trim();
4143
+ const musicFolderId = /^\d+$/.test(rawSectionId) ? Number(rawSectionId) : rawSectionId;
4144
+ const musicFolderName =
4145
+ String(plexState.musicSectionName || '').trim() ||
4146
+ String(plexState.serverName || '').trim() ||
4147
+ 'Music';
4148
+
3892
4149
  const inner = node(
3893
4150
  'musicFolders',
3894
4151
  {},
3895
- emptyNode('musicFolder', { id: plexState.musicSectionId, name: plexState.serverName }),
4152
+ emptyNode('musicFolder', { id: musicFolderId, name: musicFolderName }),
3896
4153
  );
3897
4154
  return sendSubsonicOk(reply, inner);
3898
4155
  });
@@ -3931,7 +4188,67 @@ export async function buildServer(config = loadConfig()) {
3931
4188
  return;
3932
4189
  }
3933
4190
 
3934
- return sendSubsonicOk(reply, node('nowPlaying'));
4191
+ const context = repo.getAccountPlexContext(account.id);
4192
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
4193
+ if (!plexState) {
4194
+ return;
4195
+ }
4196
+
4197
+ try {
4198
+ const sessions = [...playbackSessions.values()]
4199
+ .filter((session) =>
4200
+ session &&
4201
+ session.accountId === account.id &&
4202
+ session.itemId &&
4203
+ session.state !== 'stopped',
4204
+ )
4205
+ .sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
4206
+
4207
+ if (sessions.length === 0) {
4208
+ return sendSubsonicOk(reply, node('nowPlaying'));
4209
+ }
4210
+
4211
+ const dedupedByItemId = new Map();
4212
+ for (const session of sessions) {
4213
+ const itemId = String(session.itemId || '').trim();
4214
+ if (itemId && !dedupedByItemId.has(itemId)) {
4215
+ dedupedByItemId.set(itemId, session);
4216
+ }
4217
+ }
4218
+
4219
+ const itemIds = [...dedupedByItemId.keys()];
4220
+ const tracks = await resolveTracksByIdOrder({
4221
+ accountId: account.id,
4222
+ plexState,
4223
+ request,
4224
+ ids: itemIds,
4225
+ });
4226
+
4227
+ const sessionByTrackId = new Map(
4228
+ [...dedupedByItemId.entries()].map(([id, session]) => [id, session]),
4229
+ );
4230
+ const entries = tracks
4231
+ .map((track) => {
4232
+ const trackId = String(track?.ratingKey || '').trim();
4233
+ const session = sessionByTrackId.get(trackId);
4234
+ const minutesAgo = Math.max(
4235
+ 0,
4236
+ Math.floor((Date.now() - Number(session?.updatedAt || Date.now())) / 60000),
4237
+ );
4238
+ return emptyNode('entry', {
4239
+ ...songAttrs(track),
4240
+ username: account.username,
4241
+ playerId: session?.clientIdentifier || undefined,
4242
+ minutesAgo,
4243
+ });
4244
+ })
4245
+ .join('');
4246
+
4247
+ return sendSubsonicOk(reply, node('nowPlaying', {}, entries));
4248
+ } catch (error) {
4249
+ request.log.error(error, 'Failed to load now playing entries');
4250
+ return sendSubsonicError(reply, 10, 'Failed to load now playing');
4251
+ }
3935
4252
  });
3936
4253
 
3937
4254
  app.get('/rest/getScanStatus.view', async (request, reply) => {
@@ -5049,17 +5366,54 @@ export async function buildServer(config = loadConfig()) {
5049
5366
  return;
5050
5367
  }
5051
5368
 
5052
- return sendSubsonicOk(
5053
- reply,
5054
- node(
5055
- 'playQueue',
5056
- {
5057
- current: '',
5058
- position: 0,
5059
- },
5060
- '',
5061
- ),
5062
- );
5369
+ const context = repo.getAccountPlexContext(account.id);
5370
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
5371
+ if (!plexState) {
5372
+ return;
5373
+ }
5374
+
5375
+ pruneSavedPlayQueues();
5376
+ const queueKey = playQueueStorageKey(account.id, request);
5377
+ const queueState = savedPlayQueues.get(queueKey) || {
5378
+ ids: [],
5379
+ current: '',
5380
+ position: 0,
5381
+ updatedAt: Date.now(),
5382
+ };
5383
+
5384
+ try {
5385
+ const effectiveIds = queueState.current && !queueState.ids.includes(queueState.current)
5386
+ ? [queueState.current, ...queueState.ids]
5387
+ : queueState.ids;
5388
+ const tracks = await resolveTracksByIdOrder({
5389
+ accountId: account.id,
5390
+ plexState,
5391
+ request,
5392
+ ids: effectiveIds,
5393
+ });
5394
+
5395
+ const entries = tracks
5396
+ .map((track) => emptyNode('entry', songAttrs(track)))
5397
+ .join('');
5398
+ const changedIso = new Date(Number(queueState.updatedAt || Date.now())).toISOString();
5399
+
5400
+ return sendSubsonicOk(
5401
+ reply,
5402
+ node(
5403
+ 'playQueue',
5404
+ {
5405
+ current: queueState.current || '',
5406
+ position: parseNonNegativeInt(queueState.position, 0),
5407
+ username: account.username,
5408
+ changed: changedIso,
5409
+ },
5410
+ entries,
5411
+ ),
5412
+ );
5413
+ } catch (error) {
5414
+ request.log.error(error, 'Failed to load saved play queue');
5415
+ return sendSubsonicError(reply, 10, 'Failed to load play queue');
5416
+ }
5063
5417
  });
5064
5418
 
5065
5419
  app.get('/rest/savePlayQueue.view', async (request, reply) => {
@@ -5068,6 +5422,21 @@ export async function buildServer(config = loadConfig()) {
5068
5422
  return;
5069
5423
  }
5070
5424
 
5425
+ const ids = requestedPlayQueueItemIds(request);
5426
+ const explicitCurrent = String(getRequestParam(request, 'current') || '').trim();
5427
+ const current = explicitCurrent || ids[0] || '';
5428
+ const position = parseNonNegativeInt(getRequestParam(request, 'position'), 0);
5429
+ const queueKey = playQueueStorageKey(account.id, request);
5430
+ const now = Date.now();
5431
+
5432
+ savedPlayQueues.set(queueKey, {
5433
+ ids,
5434
+ current,
5435
+ position,
5436
+ updatedAt: now,
5437
+ });
5438
+ pruneSavedPlayQueues(now);
5439
+
5071
5440
  return sendSubsonicOk(reply);
5072
5441
  });
5073
5442
 
@@ -5867,7 +6236,61 @@ export async function buildServer(config = loadConfig()) {
5867
6236
  return;
5868
6237
  }
5869
6238
 
5870
- return sendSubsonicOk(reply, node('artistInfo'));
6239
+ const artistId = String(getRequestParam(request, 'id') || '').trim();
6240
+ if (!artistId) {
6241
+ return sendSubsonicError(reply, 70, 'Missing artist id');
6242
+ }
6243
+
6244
+ const context = repo.getAccountPlexContext(account.id);
6245
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
6246
+ if (!plexState) {
6247
+ return;
6248
+ }
6249
+
6250
+ try {
6251
+ let artist = null;
6252
+ try {
6253
+ artist = await getArtist({
6254
+ baseUrl: plexState.baseUrl,
6255
+ plexToken: plexState.plexToken,
6256
+ artistId,
6257
+ });
6258
+ } catch (error) {
6259
+ if (!isPlexNotFoundError(error)) {
6260
+ throw error;
6261
+ }
6262
+ }
6263
+
6264
+ if (!artist) {
6265
+ const fallback = await resolveArtistFromCachedLibrary({
6266
+ accountId: account.id,
6267
+ plexState,
6268
+ request,
6269
+ artistId,
6270
+ });
6271
+ if (fallback?.artist) {
6272
+ artist = fallback.artist;
6273
+ }
6274
+ }
6275
+
6276
+ if (!artist) {
6277
+ return sendSubsonicError(reply, 70, 'Artist not found');
6278
+ }
6279
+
6280
+ const biography = artistBioFromPlex(artist);
6281
+ const musicBrainzId = extractMusicBrainzArtistId(artist);
6282
+ const children = [
6283
+ biography ? node('biography', {}, biography) : '',
6284
+ musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : '',
6285
+ ]
6286
+ .filter(Boolean)
6287
+ .join('');
6288
+
6289
+ return sendSubsonicOk(reply, node('artistInfo', {}, children));
6290
+ } catch (error) {
6291
+ request.log.error(error, 'Failed to load artist info');
6292
+ return sendSubsonicError(reply, 10, 'Failed to load artist info');
6293
+ }
5871
6294
  });
5872
6295
 
5873
6296
  app.get('/rest/getArtistInfo2.view', async (request, reply) => {
@@ -5876,7 +6299,61 @@ export async function buildServer(config = loadConfig()) {
5876
6299
  return;
5877
6300
  }
5878
6301
 
5879
- return sendSubsonicOk(reply, node('artistInfo2'));
6302
+ const artistId = String(getRequestParam(request, 'id') || '').trim();
6303
+ if (!artistId) {
6304
+ return sendSubsonicError(reply, 70, 'Missing artist id');
6305
+ }
6306
+
6307
+ const context = repo.getAccountPlexContext(account.id);
6308
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
6309
+ if (!plexState) {
6310
+ return;
6311
+ }
6312
+
6313
+ try {
6314
+ let artist = null;
6315
+ try {
6316
+ artist = await getArtist({
6317
+ baseUrl: plexState.baseUrl,
6318
+ plexToken: plexState.plexToken,
6319
+ artistId,
6320
+ });
6321
+ } catch (error) {
6322
+ if (!isPlexNotFoundError(error)) {
6323
+ throw error;
6324
+ }
6325
+ }
6326
+
6327
+ if (!artist) {
6328
+ const fallback = await resolveArtistFromCachedLibrary({
6329
+ accountId: account.id,
6330
+ plexState,
6331
+ request,
6332
+ artistId,
6333
+ });
6334
+ if (fallback?.artist) {
6335
+ artist = fallback.artist;
6336
+ }
6337
+ }
6338
+
6339
+ if (!artist) {
6340
+ return sendSubsonicError(reply, 70, 'Artist not found');
6341
+ }
6342
+
6343
+ const biography = artistBioFromPlex(artist);
6344
+ const musicBrainzId = extractMusicBrainzArtistId(artist);
6345
+ const children = [
6346
+ biography ? node('biography', {}, biography) : '',
6347
+ musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : '',
6348
+ ]
6349
+ .filter(Boolean)
6350
+ .join('');
6351
+
6352
+ return sendSubsonicOk(reply, node('artistInfo2', {}, children));
6353
+ } catch (error) {
6354
+ request.log.error(error, 'Failed to load artist info2');
6355
+ return sendSubsonicError(reply, 10, 'Failed to load artist info');
6356
+ }
5880
6357
  });
5881
6358
 
5882
6359
  app.get('/rest/getAlbum.view', async (request, reply) => {
@@ -6536,10 +7013,17 @@ export async function buildServer(config = loadConfig()) {
6536
7013
 
6537
7014
  const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
6538
7015
  const rangeHeader = request.headers.range;
6539
- const upstream = await fetch(streamUrl, {
6540
- headers: {
6541
- ...(rangeHeader ? { Range: rangeHeader } : {}),
7016
+ const upstream = await fetchWithRetry({
7017
+ url: streamUrl,
7018
+ options: {
7019
+ headers: {
7020
+ ...(rangeHeader ? { Range: rangeHeader } : {}),
7021
+ },
6542
7022
  },
7023
+ request,
7024
+ context: 'track stream proxy',
7025
+ maxAttempts: 3,
7026
+ baseDelayMs: 250,
6543
7027
  });
6544
7028
 
6545
7029
  if (!upstream.ok || !upstream.body) {
@@ -207,6 +207,8 @@ const ARRAY_CHILDREN_BY_PARENT = {
207
207
  playlists: new Set(['playlist']),
208
208
  directory: new Set(['child']),
209
209
  playlist: new Set(['entry']),
210
+ playQueue: new Set(['entry']),
211
+ nowPlaying: new Set(['entry']),
210
212
  lyricsList: new Set(['structuredLyrics']),
211
213
  structuredLyrics: new Set(['line']),
212
214
  starred: new Set(['artist', 'album', 'song']),
@@ -319,6 +321,11 @@ function nodeToJson(node) {
319
321
  }
320
322
  }
321
323
 
324
+ const keys = Object.keys(out);
325
+ if (keys.length === 1 && keys[0] === 'value') {
326
+ return out.value;
327
+ }
328
+
322
329
  if (node.name === 'openSubsonicExtensions') {
323
330
  const extensions = out.openSubsonicExtension;
324
331
  if (Array.isArray(extensions)) {