plexsonic 0.1.3 → 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.3",
3
+ "version": "0.1.5",
4
4
  "description": "PlexMusic to OpenSubsonic bridge",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
package/src/plex.js CHANGED
@@ -208,6 +208,7 @@ function normalizeLibrarySection(section) {
208
208
  id: String(section.key ?? section.id ?? ''),
209
209
  title: section.title || section.name || 'Untitled',
210
210
  type: section.type || '',
211
+ scanning: toBool(section.refreshing) || toBool(section.scanning),
211
212
  updatedAt: parseTimestamp(section.updatedAt),
212
213
  scannedAt: parseTimestamp(section.scannedAt),
213
214
  refreshedAt: parseTimestamp(section.refreshedAt),
@@ -629,6 +630,20 @@ export async function listMusicSections({ baseUrl, plexToken }) {
629
630
  .filter((section) => section.type === 'artist' || section.type === 'music');
630
631
  }
631
632
 
633
+ export async function startPlexSectionScan({ baseUrl, plexToken, sectionId, force = true }) {
634
+ await fetchPms(
635
+ baseUrl,
636
+ plexToken,
637
+ `/library/sections/${encodeURIComponent(sectionId)}/refresh`,
638
+ {
639
+ method: 'GET',
640
+ searchParams: {
641
+ force: force ? 1 : 0,
642
+ },
643
+ },
644
+ );
645
+ }
646
+
632
647
  export async function listPlexSectionFolder({ baseUrl, plexToken, sectionId, folderPath = null }) {
633
648
  const path = folderPath
634
649
  ? normalizeSectionFolderPath(folderPath, sectionId)
package/src/server.js CHANGED
@@ -69,6 +69,7 @@ import {
69
69
  searchSectionHubs,
70
70
  searchSectionMetadata,
71
71
  scrobblePlexItem,
72
+ startPlexSectionScan,
72
73
  updatePlexPlaybackStatus,
73
74
  } from './plex.js';
74
75
  import { createTokenCipher } from './token-crypto.js';
@@ -933,6 +934,7 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
933
934
  [album?.parentTitle, fallbackArtistName],
934
935
  'Unknown Artist',
935
936
  );
937
+ const genre = firstGenreTag(album) || undefined;
936
938
 
937
939
  return {
938
940
  id: albumId,
@@ -948,6 +950,7 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
948
950
  duration: durationSeconds(album.duration),
949
951
  created: toIsoFromEpochSeconds(album.addedAt),
950
952
  year: album.year,
953
+ genre,
951
954
  ...subsonicRatingAttrs(album),
952
955
  };
953
956
  }
@@ -1051,7 +1054,9 @@ function songAttrs(track, albumTitle = null, albumCoverArt = null, albumMetadata
1051
1054
  [albumCoverArt, track?.parentRatingKey, track?.ratingKey],
1052
1055
  undefined,
1053
1056
  );
1054
- const genreTags = allGenreTags(track);
1057
+ const albumGenreTags = albumMetadata ? allGenreTags(albumMetadata) : [];
1058
+ const genreTagsRaw = allGenreTags(track);
1059
+ const genreTags = genreTagsRaw.length > 0 ? genreTagsRaw : albumGenreTags;
1055
1060
  const genre = genreTags[0] || undefined;
1056
1061
  const genres = genreTags.length > 0 ? genreTags.join('; ') : undefined;
1057
1062
  const discNumber = parsePositiveInt(track?.parentIndex ?? track?.discNumber, 0) || undefined;
@@ -1689,6 +1694,68 @@ function isAbortError(error) {
1689
1694
  return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
1690
1695
  }
1691
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
+
1755
+ function isPlexNotFoundError(error) {
1756
+ return String(error?.message || '').includes('(404)');
1757
+ }
1758
+
1692
1759
  function safeLower(value) {
1693
1760
  return String(value || '').toLowerCase();
1694
1761
  }
@@ -1983,6 +2050,24 @@ function resolvedGenreTagsForTrack(track, albumGenreTagMap) {
1983
2050
  return Array.isArray(albumTags) ? albumTags : [];
1984
2051
  }
1985
2052
 
2053
+ function withResolvedTrackGenres(track, albumGenreTagMap) {
2054
+ const resolvedTags = resolvedGenreTagsForTrack(track, albumGenreTagMap);
2055
+ if (resolvedTags.length === 0) {
2056
+ return track;
2057
+ }
2058
+
2059
+ const currentTags = allGenreTags(track);
2060
+ if (currentTags.length > 0) {
2061
+ return track;
2062
+ }
2063
+
2064
+ return {
2065
+ ...track,
2066
+ Genre: resolvedTags.map((tag) => ({ tag })),
2067
+ genre: resolvedTags.join('; '),
2068
+ };
2069
+ }
2070
+
1986
2071
  function firstGenreTag(item) {
1987
2072
  const tags = allGenreTags(item);
1988
2073
  return tags[0] || null;
@@ -2093,6 +2178,7 @@ function requiredPlexStateForSubsonic(reply, plexContext, tokenCipher) {
2093
2178
  baseUrl: plexContext.server_base_url,
2094
2179
  machineId: plexContext.machine_id,
2095
2180
  musicSectionId: plexContext.music_section_id,
2181
+ musicSectionName: plexContext.music_section_name || null,
2096
2182
  serverName: plexContext.server_name || 'Plex Music',
2097
2183
  };
2098
2184
  }
@@ -2260,11 +2346,13 @@ export async function buildServer(config = loadConfig()) {
2260
2346
  });
2261
2347
 
2262
2348
  const playbackSessions = new Map();
2349
+ const savedPlayQueues = new Map();
2263
2350
  const PLAYBACK_RECONCILE_INTERVAL_MS = 15000;
2264
2351
  const PLAYBACK_IDLE_TIMEOUT_MS = 120000;
2265
2352
  const STREAM_DISCONNECT_STOP_DELAY_MS = 4000;
2266
2353
  const PLAYBACK_MAX_DISCONNECT_WAIT_MS = 30 * 60 * 1000;
2267
2354
  const STREAM_PROGRESS_HEARTBEAT_MS = 10000;
2355
+ const PLAY_QUEUE_IDLE_TTL_MS = 6 * 60 * 60 * 1000;
2268
2356
  const activeSearchRequests = new Map();
2269
2357
  const searchBrowseCache = new Map();
2270
2358
  const SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS = 15000;
@@ -2395,12 +2483,21 @@ export async function buildServer(config = loadConfig()) {
2395
2483
  }
2396
2484
 
2397
2485
  async function loadLibraryTracksRaw({ plexState }) {
2398
- const loaded = await listTracks({
2399
- baseUrl: plexState.baseUrl,
2400
- plexToken: plexState.plexToken,
2401
- sectionId: plexState.musicSectionId,
2402
- });
2403
- return sortTracksForLibraryBrowse(loaded);
2486
+ const [tracks, albums] = await Promise.all([
2487
+ listTracks({
2488
+ baseUrl: plexState.baseUrl,
2489
+ plexToken: plexState.plexToken,
2490
+ sectionId: plexState.musicSectionId,
2491
+ }),
2492
+ listAlbums({
2493
+ baseUrl: plexState.baseUrl,
2494
+ plexToken: plexState.plexToken,
2495
+ sectionId: plexState.musicSectionId,
2496
+ }),
2497
+ ]);
2498
+ const albumGenreTagMap = buildAlbumGenreTagMap(albums);
2499
+ const enrichedTracks = tracks.map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
2500
+ return sortTracksForLibraryBrowse(enrichedTracks);
2404
2501
  }
2405
2502
 
2406
2503
  function getLibraryCollectionLoader(collection, plexState) {
@@ -2855,6 +2952,90 @@ export async function buildServer(config = loadConfig()) {
2855
2952
  });
2856
2953
  }
2857
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
+
2858
3039
  function applyCachedRatingOverridesForAccount({ accountId, plexState, items }) {
2859
3040
  if (!Array.isArray(items) || items.length === 0) {
2860
3041
  return 0;
@@ -2867,6 +3048,62 @@ export async function buildServer(config = loadConfig()) {
2867
3048
  return applyRatingOverridesToItems(entry, items, Date.now());
2868
3049
  }
2869
3050
 
3051
+ async function resolveArtistFromCachedLibrary({ accountId, plexState, request, artistId }) {
3052
+ const normalizedArtistId = String(artistId || '').trim();
3053
+ if (!normalizedArtistId) {
3054
+ return null;
3055
+ }
3056
+
3057
+ const [artists, albums, tracks] = await Promise.all([
3058
+ getCachedLibraryArtists({ accountId, plexState, request }),
3059
+ getCachedLibraryAlbums({ accountId, plexState, request }),
3060
+ getCachedLibraryTracks({ accountId, plexState, request }),
3061
+ ]);
3062
+
3063
+ const cachedArtist = artists.find((item) => String(item?.ratingKey || '').trim() === normalizedArtistId) || null;
3064
+ const cachedAlbums = albums.filter((item) => String(item?.parentRatingKey || '').trim() === normalizedArtistId);
3065
+ const artistTracks = tracks.filter((item) => String(item?.grandparentRatingKey || '').trim() === normalizedArtistId);
3066
+
3067
+ if (!cachedArtist && cachedAlbums.length === 0 && artistTracks.length === 0) {
3068
+ return null;
3069
+ }
3070
+
3071
+ const artistName = cachedArtist?.title ||
3072
+ firstNonEmptyText(artistTracks.map((item) => item?.grandparentTitle), `Artist ${normalizedArtistId}`);
3073
+
3074
+ const artist = cachedArtist || {
3075
+ ratingKey: normalizedArtistId,
3076
+ title: artistName,
3077
+ addedAt: artistTracks[0]?.addedAt,
3078
+ updatedAt: artistTracks[0]?.updatedAt,
3079
+ };
3080
+
3081
+ const resolvedAlbums = cachedAlbums.length > 0
3082
+ ? cachedAlbums
3083
+ : deriveAlbumsFromTracks(artistTracks, normalizedArtistId, artistName);
3084
+
3085
+ return {
3086
+ artist,
3087
+ albums: resolvedAlbums,
3088
+ };
3089
+ }
3090
+
3091
+ function scanStatusAttrsFromSection(section, { fallbackScanning = false } = {}) {
3092
+ return {
3093
+ scanning: Boolean(section?.scanning ?? fallbackScanning),
3094
+ count: parseNonNegativeInt(section?.leafCount, 0),
3095
+ };
3096
+ }
3097
+
3098
+ async function getMusicSectionScanStatus(plexState) {
3099
+ const sections = await listMusicSections({
3100
+ baseUrl: plexState.baseUrl,
3101
+ plexToken: plexState.plexToken,
3102
+ });
3103
+ const section = sections.find((item) => String(item?.id || '') === String(plexState.musicSectionId || ''));
3104
+ return section || null;
3105
+ }
3106
+
2870
3107
  function beginSearchRequest(request, accountId) {
2871
3108
  const clientName =
2872
3109
  getRequestParam(request, 'c') ||
@@ -3797,10 +4034,17 @@ export async function buildServer(config = loadConfig()) {
3797
4034
  return;
3798
4035
  }
3799
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
+
3800
4044
  const inner = node(
3801
4045
  'musicFolders',
3802
4046
  {},
3803
- emptyNode('musicFolder', { id: plexState.musicSectionId, name: plexState.serverName }),
4047
+ emptyNode('musicFolder', { id: musicFolderId, name: musicFolderName }),
3804
4048
  );
3805
4049
  return sendSubsonicOk(reply, inner);
3806
4050
  });
@@ -3839,7 +4083,67 @@ export async function buildServer(config = loadConfig()) {
3839
4083
  return;
3840
4084
  }
3841
4085
 
3842
- 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
+ }
3843
4147
  });
3844
4148
 
3845
4149
  app.get('/rest/getScanStatus.view', async (request, reply) => {
@@ -3848,13 +4152,22 @@ export async function buildServer(config = loadConfig()) {
3848
4152
  return;
3849
4153
  }
3850
4154
 
3851
- return sendSubsonicOk(
3852
- reply,
3853
- emptyNode('scanStatus', {
3854
- scanning: false,
3855
- count: 0,
3856
- }),
3857
- );
4155
+ const context = repo.getAccountPlexContext(account.id);
4156
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
4157
+ if (!plexState) {
4158
+ return;
4159
+ }
4160
+
4161
+ try {
4162
+ const section = await getMusicSectionScanStatus(plexState);
4163
+ return sendSubsonicOk(
4164
+ reply,
4165
+ emptyNode('scanStatus', scanStatusAttrsFromSection(section)),
4166
+ );
4167
+ } catch (error) {
4168
+ request.log.error(error, 'Failed to load scan status from Plex');
4169
+ return sendSubsonicError(reply, 10, 'Failed to load scan status');
4170
+ }
3858
4171
  });
3859
4172
 
3860
4173
  app.get('/rest/startScan.view', async (request, reply) => {
@@ -3863,13 +4176,31 @@ export async function buildServer(config = loadConfig()) {
3863
4176
  return;
3864
4177
  }
3865
4178
 
3866
- return sendSubsonicOk(
3867
- reply,
3868
- emptyNode('scanStatus', {
3869
- scanning: false,
3870
- count: 0,
3871
- }),
3872
- );
4179
+ const context = repo.getAccountPlexContext(account.id);
4180
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
4181
+ if (!plexState) {
4182
+ return;
4183
+ }
4184
+
4185
+ try {
4186
+ await startPlexSectionScan({
4187
+ baseUrl: plexState.baseUrl,
4188
+ plexToken: plexState.plexToken,
4189
+ sectionId: plexState.musicSectionId,
4190
+ force: true,
4191
+ });
4192
+
4193
+ markSearchBrowseCacheDirty(searchBrowseCacheKey(account.id, plexState));
4194
+
4195
+ const section = await getMusicSectionScanStatus(plexState);
4196
+ return sendSubsonicOk(
4197
+ reply,
4198
+ emptyNode('scanStatus', scanStatusAttrsFromSection(section, { fallbackScanning: true })),
4199
+ );
4200
+ } catch (error) {
4201
+ request.log.error(error, 'Failed to trigger Plex scan');
4202
+ return sendSubsonicError(reply, 10, 'Failed to trigger scan');
4203
+ }
3873
4204
  });
3874
4205
 
3875
4206
  app.get('/rest/getStarred.view', async (request, reply) => {
@@ -4073,7 +4404,8 @@ export async function buildServer(config = loadConfig()) {
4073
4404
  );
4074
4405
 
4075
4406
  const sortedSongs = sortTracksForLibraryBrowse(matchedSongs);
4076
- const page = takePage(sortedSongs, offset, count);
4407
+ const page = takePage(sortedSongs, offset, count)
4408
+ .map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
4077
4409
  const songXml = page.map((track) => emptyNode('song', songAttrs(track))).join('');
4078
4410
  return sendSubsonicOk(reply, node('songsByGenre', {}, songXml));
4079
4411
  } catch (error) {
@@ -4337,7 +4669,7 @@ export async function buildServer(config = loadConfig()) {
4337
4669
  }
4338
4670
 
4339
4671
  try {
4340
- const track = await getTrack({
4672
+ let track = await getTrack({
4341
4673
  baseUrl: plexState.baseUrl,
4342
4674
  plexToken: plexState.plexToken,
4343
4675
  trackId: id,
@@ -4345,6 +4677,25 @@ export async function buildServer(config = loadConfig()) {
4345
4677
  if (!track) {
4346
4678
  return sendSubsonicError(reply, 70, 'Song not found');
4347
4679
  }
4680
+
4681
+ if (!firstGenreTag(track)) {
4682
+ const albumId = String(track?.parentRatingKey || '').trim();
4683
+ if (albumId) {
4684
+ try {
4685
+ const album = await getAlbum({
4686
+ baseUrl: plexState.baseUrl,
4687
+ plexToken: plexState.plexToken,
4688
+ albumId,
4689
+ });
4690
+ if (album) {
4691
+ const albumGenreTagMap = buildAlbumGenreTagMap([album]);
4692
+ track = withResolvedTrackGenres(track, albumGenreTagMap);
4693
+ }
4694
+ } catch (error) {
4695
+ request.log.debug(error, 'Failed to enrich song genre from album metadata');
4696
+ }
4697
+ }
4698
+ }
4348
4699
  applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [track] });
4349
4700
 
4350
4701
  return sendSubsonicOk(reply, node('song', songAttrs(track)));
@@ -4910,17 +5261,54 @@ export async function buildServer(config = loadConfig()) {
4910
5261
  return;
4911
5262
  }
4912
5263
 
4913
- return sendSubsonicOk(
4914
- reply,
4915
- node(
4916
- 'playQueue',
4917
- {
4918
- current: '',
4919
- position: 0,
4920
- },
4921
- '',
4922
- ),
4923
- );
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
+ }
4924
5312
  });
4925
5313
 
4926
5314
  app.get('/rest/savePlayQueue.view', async (request, reply) => {
@@ -4929,6 +5317,21 @@ export async function buildServer(config = loadConfig()) {
4929
5317
  return;
4930
5318
  }
4931
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
+
4932
5335
  return sendSubsonicOk(reply);
4933
5336
  });
4934
5337
 
@@ -5651,32 +6054,51 @@ export async function buildServer(config = loadConfig()) {
5651
6054
  }
5652
6055
 
5653
6056
  try {
5654
- const artist = await getArtist({
5655
- baseUrl: plexState.baseUrl,
5656
- plexToken: plexState.plexToken,
5657
- artistId,
5658
- });
6057
+ let artist = null;
6058
+ let finalAlbums = [];
5659
6059
 
5660
- if (!artist) {
5661
- return sendSubsonicError(reply, 70, 'Artist not found');
6060
+ try {
6061
+ artist = await getArtist({
6062
+ baseUrl: plexState.baseUrl,
6063
+ plexToken: plexState.plexToken,
6064
+ artistId,
6065
+ });
6066
+ } catch (error) {
6067
+ if (!isPlexNotFoundError(error)) {
6068
+ throw error;
6069
+ }
5662
6070
  }
5663
- applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
5664
6071
 
5665
- const albums = await listArtistAlbums({
5666
- baseUrl: plexState.baseUrl,
5667
- plexToken: plexState.plexToken,
5668
- artistId,
5669
- });
5670
- applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
5671
-
5672
- let finalAlbums = albums;
5673
- if (finalAlbums.length === 0) {
5674
- const tracks = await listArtistTracks({
6072
+ if (artist) {
6073
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
6074
+ const albums = await listArtistAlbums({
5675
6075
  baseUrl: plexState.baseUrl,
5676
6076
  plexToken: plexState.plexToken,
5677
6077
  artistId,
5678
6078
  });
5679
- finalAlbums = deriveAlbumsFromTracks(tracks, artist.ratingKey, artist.title);
6079
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
6080
+ finalAlbums = albums;
6081
+ if (finalAlbums.length === 0) {
6082
+ const tracks = await listArtistTracks({
6083
+ baseUrl: plexState.baseUrl,
6084
+ plexToken: plexState.plexToken,
6085
+ artistId,
6086
+ });
6087
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
6088
+ finalAlbums = deriveAlbumsFromTracks(tracks, artist.ratingKey, artist.title);
6089
+ }
6090
+ } else {
6091
+ const fallback = await resolveArtistFromCachedLibrary({
6092
+ accountId: account.id,
6093
+ plexState,
6094
+ request,
6095
+ artistId,
6096
+ });
6097
+ if (!fallback?.artist) {
6098
+ return sendSubsonicError(reply, 70, 'Artist not found');
6099
+ }
6100
+ artist = fallback.artist;
6101
+ finalAlbums = fallback.albums || [];
5680
6102
  }
5681
6103
 
5682
6104
  const albumXml = finalAlbums
@@ -5882,11 +6304,18 @@ export async function buildServer(config = loadConfig()) {
5882
6304
  );
5883
6305
  }
5884
6306
 
5885
- const artist = await getArtist({
5886
- baseUrl: plexState.baseUrl,
5887
- plexToken: plexState.plexToken,
5888
- artistId: id,
5889
- });
6307
+ let artist = null;
6308
+ try {
6309
+ artist = await getArtist({
6310
+ baseUrl: plexState.baseUrl,
6311
+ plexToken: plexState.plexToken,
6312
+ artistId: id,
6313
+ });
6314
+ } catch (error) {
6315
+ if (!isPlexNotFoundError(error)) {
6316
+ throw error;
6317
+ }
6318
+ }
5890
6319
 
5891
6320
  if (artist) {
5892
6321
  applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
@@ -5925,6 +6354,34 @@ export async function buildServer(config = loadConfig()) {
5925
6354
  );
5926
6355
  }
5927
6356
 
6357
+ if (!artist) {
6358
+ const fallback = await resolveArtistFromCachedLibrary({
6359
+ accountId: account.id,
6360
+ plexState,
6361
+ request,
6362
+ artistId: id,
6363
+ });
6364
+ if (fallback?.artist) {
6365
+ const fallbackAlbums = fallback.albums || [];
6366
+ const children = fallbackAlbums
6367
+ .map((album) => emptyNode('child', albumAttrs(album, fallback.artist.ratingKey, fallback.artist.title)))
6368
+ .join('');
6369
+
6370
+ return sendSubsonicOk(
6371
+ reply,
6372
+ node(
6373
+ 'directory',
6374
+ {
6375
+ id: fallback.artist.ratingKey,
6376
+ parent: rootDirectoryId,
6377
+ name: fallback.artist.title,
6378
+ },
6379
+ children,
6380
+ ),
6381
+ );
6382
+ }
6383
+ }
6384
+
5928
6385
  const album = await getAlbum({
5929
6386
  baseUrl: plexState.baseUrl,
5930
6387
  plexToken: plexState.plexToken,
@@ -6343,10 +6800,17 @@ export async function buildServer(config = loadConfig()) {
6343
6800
 
6344
6801
  const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
6345
6802
  const rangeHeader = request.headers.range;
6346
- const upstream = await fetch(streamUrl, {
6347
- headers: {
6348
- ...(rangeHeader ? { Range: rangeHeader } : {}),
6803
+ const upstream = await fetchWithRetry({
6804
+ url: streamUrl,
6805
+ options: {
6806
+ headers: {
6807
+ ...(rangeHeader ? { Range: rangeHeader } : {}),
6808
+ },
6349
6809
  },
6810
+ request,
6811
+ context: 'track stream proxy',
6812
+ maxAttempts: 3,
6813
+ baseDelayMs: 250,
6350
6814
  });
6351
6815
 
6352
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']),