plexsonic 0.1.4 → 0.1.5

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.5",
4
4
  "description": "PlexMusic to OpenSubsonic bridge",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
package/src/server.js CHANGED
@@ -1694,6 +1694,64 @@ function isAbortError(error) {
1694
1694
  return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
1695
1695
  }
1696
1696
 
1697
+ const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
1698
+
1699
+ function waitMs(ms) {
1700
+ return new Promise((resolve) => {
1701
+ setTimeout(resolve, ms);
1702
+ });
1703
+ }
1704
+
1705
+ async function fetchWithRetry({
1706
+ url,
1707
+ options = {},
1708
+ request = null,
1709
+ context = 'upstream request',
1710
+ maxAttempts = 3,
1711
+ baseDelayMs = 200,
1712
+ }) {
1713
+ let lastError = null;
1714
+
1715
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1716
+ try {
1717
+ const response = await fetch(url, options);
1718
+ if (!RETRYABLE_UPSTREAM_STATUSES.has(response.status) || attempt >= maxAttempts) {
1719
+ return response;
1720
+ }
1721
+
1722
+ request?.log?.warn(
1723
+ { context, status: response.status, attempt, maxAttempts },
1724
+ 'Transient upstream failure, retrying',
1725
+ );
1726
+
1727
+ try {
1728
+ await response.body?.cancel?.();
1729
+ } catch {}
1730
+ } catch (error) {
1731
+ if (isAbortError(error)) {
1732
+ throw error;
1733
+ }
1734
+ lastError = error;
1735
+ if (attempt >= maxAttempts) {
1736
+ throw error;
1737
+ }
1738
+
1739
+ request?.log?.warn(
1740
+ { context, attempt, maxAttempts, message: error?.message || String(error) },
1741
+ 'Transient upstream error, retrying',
1742
+ );
1743
+ }
1744
+
1745
+ await waitMs(baseDelayMs * attempt);
1746
+ }
1747
+
1748
+ if (lastError) {
1749
+ throw lastError;
1750
+ }
1751
+
1752
+ throw new Error(`${context} failed after retries`);
1753
+ }
1754
+
1697
1755
  function isPlexNotFoundError(error) {
1698
1756
  return String(error?.message || '').includes('(404)');
1699
1757
  }
@@ -2120,6 +2178,7 @@ function requiredPlexStateForSubsonic(reply, plexContext, tokenCipher) {
2120
2178
  baseUrl: plexContext.server_base_url,
2121
2179
  machineId: plexContext.machine_id,
2122
2180
  musicSectionId: plexContext.music_section_id,
2181
+ musicSectionName: plexContext.music_section_name || null,
2123
2182
  serverName: plexContext.server_name || 'Plex Music',
2124
2183
  };
2125
2184
  }
@@ -2287,11 +2346,13 @@ export async function buildServer(config = loadConfig()) {
2287
2346
  });
2288
2347
 
2289
2348
  const playbackSessions = new Map();
2349
+ const savedPlayQueues = new Map();
2290
2350
  const PLAYBACK_RECONCILE_INTERVAL_MS = 15000;
2291
2351
  const PLAYBACK_IDLE_TIMEOUT_MS = 120000;
2292
2352
  const STREAM_DISCONNECT_STOP_DELAY_MS = 4000;
2293
2353
  const PLAYBACK_MAX_DISCONNECT_WAIT_MS = 30 * 60 * 1000;
2294
2354
  const STREAM_PROGRESS_HEARTBEAT_MS = 10000;
2355
+ const PLAY_QUEUE_IDLE_TTL_MS = 6 * 60 * 60 * 1000;
2295
2356
  const activeSearchRequests = new Map();
2296
2357
  const searchBrowseCache = new Map();
2297
2358
  const SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS = 15000;
@@ -2891,6 +2952,90 @@ export async function buildServer(config = loadConfig()) {
2891
2952
  });
2892
2953
  }
2893
2954
 
2955
+ function pruneSavedPlayQueues(now = Date.now()) {
2956
+ for (const [key, value] of savedPlayQueues.entries()) {
2957
+ if (!value || (now - Number(value.updatedAt || 0)) > PLAY_QUEUE_IDLE_TTL_MS) {
2958
+ savedPlayQueues.delete(key);
2959
+ }
2960
+ }
2961
+ }
2962
+
2963
+ function playQueueClientKey(request) {
2964
+ const rawClient =
2965
+ getRequestParam(request, 'c') ||
2966
+ String(request.headers?.['user-agent'] || '').trim() ||
2967
+ 'subsonic-client';
2968
+ return safeLower(rawClient).slice(0, 128) || 'subsonic-client';
2969
+ }
2970
+
2971
+ function playQueueStorageKey(accountId, request) {
2972
+ return `${accountId}:${playQueueClientKey(request)}`;
2973
+ }
2974
+
2975
+ function requestedPlayQueueItemIds(request) {
2976
+ const ids = getRequestParamValues(request, 'id');
2977
+ if (ids.length > 0) {
2978
+ return ids;
2979
+ }
2980
+ return getRequestParamValues(request, 'songId');
2981
+ }
2982
+
2983
+ async function resolveTracksByIdOrder({ accountId, plexState, request, ids }) {
2984
+ const orderedIds = (Array.isArray(ids) ? ids : [])
2985
+ .map((id) => String(id || '').trim())
2986
+ .filter(Boolean);
2987
+ if (orderedIds.length === 0) {
2988
+ return [];
2989
+ }
2990
+
2991
+ const tracks = await getCachedLibraryTracks({ accountId, plexState, request });
2992
+ const cachedById = new Map();
2993
+ for (const track of tracks) {
2994
+ const ratingKey = String(track?.ratingKey || '').trim();
2995
+ if (ratingKey && !cachedById.has(ratingKey)) {
2996
+ cachedById.set(ratingKey, track);
2997
+ }
2998
+ }
2999
+
3000
+ const missingIds = [];
3001
+ for (const id of orderedIds) {
3002
+ if (!cachedById.has(id)) {
3003
+ missingIds.push(id);
3004
+ }
3005
+ }
3006
+
3007
+ if (missingIds.length > 0) {
3008
+ const fetched = await Promise.all(
3009
+ missingIds.map(async (trackId) => {
3010
+ try {
3011
+ const track = await getTrack({
3012
+ baseUrl: plexState.baseUrl,
3013
+ plexToken: plexState.plexToken,
3014
+ trackId,
3015
+ });
3016
+ return track || null;
3017
+ } catch {
3018
+ return null;
3019
+ }
3020
+ }),
3021
+ );
3022
+
3023
+ const nonNullFetched = fetched.filter(Boolean);
3024
+ applyCachedRatingOverridesForAccount({ accountId, plexState, items: nonNullFetched });
3025
+
3026
+ for (const track of nonNullFetched) {
3027
+ const ratingKey = String(track?.ratingKey || '').trim();
3028
+ if (ratingKey && !cachedById.has(ratingKey)) {
3029
+ cachedById.set(ratingKey, track);
3030
+ }
3031
+ }
3032
+ }
3033
+
3034
+ return orderedIds
3035
+ .map((id) => cachedById.get(id))
3036
+ .filter(Boolean);
3037
+ }
3038
+
2894
3039
  function applyCachedRatingOverridesForAccount({ accountId, plexState, items }) {
2895
3040
  if (!Array.isArray(items) || items.length === 0) {
2896
3041
  return 0;
@@ -3889,10 +4034,17 @@ export async function buildServer(config = loadConfig()) {
3889
4034
  return;
3890
4035
  }
3891
4036
 
4037
+ const rawSectionId = String(plexState.musicSectionId || '').trim();
4038
+ const musicFolderId = /^\d+$/.test(rawSectionId) ? Number(rawSectionId) : rawSectionId;
4039
+ const musicFolderName =
4040
+ String(plexState.musicSectionName || '').trim() ||
4041
+ String(plexState.serverName || '').trim() ||
4042
+ 'Music';
4043
+
3892
4044
  const inner = node(
3893
4045
  'musicFolders',
3894
4046
  {},
3895
- emptyNode('musicFolder', { id: plexState.musicSectionId, name: plexState.serverName }),
4047
+ emptyNode('musicFolder', { id: musicFolderId, name: musicFolderName }),
3896
4048
  );
3897
4049
  return sendSubsonicOk(reply, inner);
3898
4050
  });
@@ -3931,7 +4083,67 @@ export async function buildServer(config = loadConfig()) {
3931
4083
  return;
3932
4084
  }
3933
4085
 
3934
- return sendSubsonicOk(reply, node('nowPlaying'));
4086
+ const context = repo.getAccountPlexContext(account.id);
4087
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
4088
+ if (!plexState) {
4089
+ return;
4090
+ }
4091
+
4092
+ try {
4093
+ const sessions = [...playbackSessions.values()]
4094
+ .filter((session) =>
4095
+ session &&
4096
+ session.accountId === account.id &&
4097
+ session.itemId &&
4098
+ session.state !== 'stopped',
4099
+ )
4100
+ .sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
4101
+
4102
+ if (sessions.length === 0) {
4103
+ return sendSubsonicOk(reply, node('nowPlaying'));
4104
+ }
4105
+
4106
+ const dedupedByItemId = new Map();
4107
+ for (const session of sessions) {
4108
+ const itemId = String(session.itemId || '').trim();
4109
+ if (itemId && !dedupedByItemId.has(itemId)) {
4110
+ dedupedByItemId.set(itemId, session);
4111
+ }
4112
+ }
4113
+
4114
+ const itemIds = [...dedupedByItemId.keys()];
4115
+ const tracks = await resolveTracksByIdOrder({
4116
+ accountId: account.id,
4117
+ plexState,
4118
+ request,
4119
+ ids: itemIds,
4120
+ });
4121
+
4122
+ const sessionByTrackId = new Map(
4123
+ [...dedupedByItemId.entries()].map(([id, session]) => [id, session]),
4124
+ );
4125
+ const entries = tracks
4126
+ .map((track) => {
4127
+ const trackId = String(track?.ratingKey || '').trim();
4128
+ const session = sessionByTrackId.get(trackId);
4129
+ const minutesAgo = Math.max(
4130
+ 0,
4131
+ Math.floor((Date.now() - Number(session?.updatedAt || Date.now())) / 60000),
4132
+ );
4133
+ return emptyNode('entry', {
4134
+ ...songAttrs(track),
4135
+ username: account.username,
4136
+ playerId: session?.clientIdentifier || undefined,
4137
+ minutesAgo,
4138
+ });
4139
+ })
4140
+ .join('');
4141
+
4142
+ return sendSubsonicOk(reply, node('nowPlaying', {}, entries));
4143
+ } catch (error) {
4144
+ request.log.error(error, 'Failed to load now playing entries');
4145
+ return sendSubsonicError(reply, 10, 'Failed to load now playing');
4146
+ }
3935
4147
  });
3936
4148
 
3937
4149
  app.get('/rest/getScanStatus.view', async (request, reply) => {
@@ -5049,17 +5261,54 @@ export async function buildServer(config = loadConfig()) {
5049
5261
  return;
5050
5262
  }
5051
5263
 
5052
- return sendSubsonicOk(
5053
- reply,
5054
- node(
5055
- 'playQueue',
5056
- {
5057
- current: '',
5058
- position: 0,
5059
- },
5060
- '',
5061
- ),
5062
- );
5264
+ const context = repo.getAccountPlexContext(account.id);
5265
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
5266
+ if (!plexState) {
5267
+ return;
5268
+ }
5269
+
5270
+ pruneSavedPlayQueues();
5271
+ const queueKey = playQueueStorageKey(account.id, request);
5272
+ const queueState = savedPlayQueues.get(queueKey) || {
5273
+ ids: [],
5274
+ current: '',
5275
+ position: 0,
5276
+ updatedAt: Date.now(),
5277
+ };
5278
+
5279
+ try {
5280
+ const effectiveIds = queueState.current && !queueState.ids.includes(queueState.current)
5281
+ ? [queueState.current, ...queueState.ids]
5282
+ : queueState.ids;
5283
+ const tracks = await resolveTracksByIdOrder({
5284
+ accountId: account.id,
5285
+ plexState,
5286
+ request,
5287
+ ids: effectiveIds,
5288
+ });
5289
+
5290
+ const entries = tracks
5291
+ .map((track) => emptyNode('entry', songAttrs(track)))
5292
+ .join('');
5293
+ const changedIso = new Date(Number(queueState.updatedAt || Date.now())).toISOString();
5294
+
5295
+ return sendSubsonicOk(
5296
+ reply,
5297
+ node(
5298
+ 'playQueue',
5299
+ {
5300
+ current: queueState.current || '',
5301
+ position: parseNonNegativeInt(queueState.position, 0),
5302
+ username: account.username,
5303
+ changed: changedIso,
5304
+ },
5305
+ entries,
5306
+ ),
5307
+ );
5308
+ } catch (error) {
5309
+ request.log.error(error, 'Failed to load saved play queue');
5310
+ return sendSubsonicError(reply, 10, 'Failed to load play queue');
5311
+ }
5063
5312
  });
5064
5313
 
5065
5314
  app.get('/rest/savePlayQueue.view', async (request, reply) => {
@@ -5068,6 +5317,21 @@ export async function buildServer(config = loadConfig()) {
5068
5317
  return;
5069
5318
  }
5070
5319
 
5320
+ const ids = requestedPlayQueueItemIds(request);
5321
+ const explicitCurrent = String(getRequestParam(request, 'current') || '').trim();
5322
+ const current = explicitCurrent || ids[0] || '';
5323
+ const position = parseNonNegativeInt(getRequestParam(request, 'position'), 0);
5324
+ const queueKey = playQueueStorageKey(account.id, request);
5325
+ const now = Date.now();
5326
+
5327
+ savedPlayQueues.set(queueKey, {
5328
+ ids,
5329
+ current,
5330
+ position,
5331
+ updatedAt: now,
5332
+ });
5333
+ pruneSavedPlayQueues(now);
5334
+
5071
5335
  return sendSubsonicOk(reply);
5072
5336
  });
5073
5337
 
@@ -6536,10 +6800,17 @@ export async function buildServer(config = loadConfig()) {
6536
6800
 
6537
6801
  const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
6538
6802
  const rangeHeader = request.headers.range;
6539
- const upstream = await fetch(streamUrl, {
6540
- headers: {
6541
- ...(rangeHeader ? { Range: rangeHeader } : {}),
6803
+ const upstream = await fetchWithRetry({
6804
+ url: streamUrl,
6805
+ options: {
6806
+ headers: {
6807
+ ...(rangeHeader ? { Range: rangeHeader } : {}),
6808
+ },
6542
6809
  },
6810
+ request,
6811
+ context: 'track stream proxy',
6812
+ maxAttempts: 3,
6813
+ baseDelayMs: 250,
6543
6814
  });
6544
6815
 
6545
6816
  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']),