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 +1 -1
- package/src/server.js +287 -16
- package/src/subsonic-xml.js +2 -0
package/package.json
CHANGED
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:
|
|
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
|
-
|
|
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
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
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
|
|
6540
|
-
|
|
6541
|
-
|
|
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) {
|
package/src/subsonic-xml.js
CHANGED
|
@@ -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']),
|