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