plexsonic 0.1.5 → 0.1.7
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 +613 -75
- package/src/subsonic-xml.js +5 -0
package/package.json
CHANGED
package/src/server.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { createHash, randomUUID } from 'node:crypto';
|
|
22
|
-
import { Readable } from 'node:stream';
|
|
22
|
+
import { PassThrough, Readable } from 'node:stream';
|
|
23
23
|
import Fastify from 'fastify';
|
|
24
24
|
import argon2 from 'argon2';
|
|
25
25
|
import fastifyCookie from '@fastify/cookie';
|
|
@@ -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,35 @@ function isAbortError(error) {
|
|
|
1694
1799
|
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
|
|
1695
1800
|
}
|
|
1696
1801
|
|
|
1802
|
+
function isUpstreamTerminationError(error) {
|
|
1803
|
+
if (!error) {
|
|
1804
|
+
return false;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
const message = String(error?.message || '').toLowerCase();
|
|
1808
|
+
const causeMessage = String(error?.cause?.message || '').toLowerCase();
|
|
1809
|
+
const code = String(error?.code || error?.cause?.code || '').toUpperCase();
|
|
1810
|
+
const causeName = String(error?.cause?.name || '').toLowerCase();
|
|
1811
|
+
|
|
1812
|
+
return (
|
|
1813
|
+
code === 'ECONNRESET' ||
|
|
1814
|
+
code === 'EPIPE' ||
|
|
1815
|
+
message.includes('terminated') ||
|
|
1816
|
+
message.includes('other side closed') ||
|
|
1817
|
+
causeMessage.includes('other side closed') ||
|
|
1818
|
+
causeName.includes('socketerror')
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function isClientDisconnected(request, reply) {
|
|
1823
|
+
return Boolean(
|
|
1824
|
+
request?.raw?.aborted ||
|
|
1825
|
+
request?.raw?.destroyed ||
|
|
1826
|
+
reply?.raw?.destroyed ||
|
|
1827
|
+
reply?.raw?.writableEnded,
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1697
1831
|
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
1698
1832
|
|
|
1699
1833
|
function waitMs(ms) {
|
|
@@ -2346,12 +2480,16 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2346
2480
|
});
|
|
2347
2481
|
|
|
2348
2482
|
const playbackSessions = new Map();
|
|
2483
|
+
const recentScrobblesByClient = new Map();
|
|
2349
2484
|
const savedPlayQueues = new Map();
|
|
2350
2485
|
const PLAYBACK_RECONCILE_INTERVAL_MS = 15000;
|
|
2351
2486
|
const PLAYBACK_IDLE_TIMEOUT_MS = 120000;
|
|
2352
2487
|
const STREAM_DISCONNECT_STOP_DELAY_MS = 4000;
|
|
2353
2488
|
const PLAYBACK_MAX_DISCONNECT_WAIT_MS = 30 * 60 * 1000;
|
|
2354
2489
|
const STREAM_PROGRESS_HEARTBEAT_MS = 10000;
|
|
2490
|
+
const STREAM_PRELOAD_SUPPRESS_AFTER_SCROBBLE_MS = 1500;
|
|
2491
|
+
const STREAM_SUPPRESSED_PROMOTE_DELAY_MS = 1200;
|
|
2492
|
+
const PLAYBACK_CONTINUITY_AFTER_SCROBBLE_MS = 15000;
|
|
2355
2493
|
const PLAY_QUEUE_IDLE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
2356
2494
|
const activeSearchRequests = new Map();
|
|
2357
2495
|
const searchBrowseCache = new Map();
|
|
@@ -3161,6 +3299,116 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3161
3299
|
};
|
|
3162
3300
|
}
|
|
3163
3301
|
|
|
3302
|
+
function pruneRecentScrobbles(now = Date.now()) {
|
|
3303
|
+
void now;
|
|
3304
|
+
for (const [key, value] of recentScrobblesByClient.entries()) {
|
|
3305
|
+
if (!value) {
|
|
3306
|
+
recentScrobblesByClient.delete(key);
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
function getPlaybackContinuityState(accountId, clientName) {
|
|
3312
|
+
const playbackClient = playbackClientContext(accountId, clientName);
|
|
3313
|
+
let state = recentScrobblesByClient.get(playbackClient.sessionKey);
|
|
3314
|
+
if (!state) {
|
|
3315
|
+
state = {
|
|
3316
|
+
at: 0,
|
|
3317
|
+
trackId: '',
|
|
3318
|
+
queueCurrentId: '',
|
|
3319
|
+
queueIds: [],
|
|
3320
|
+
};
|
|
3321
|
+
recentScrobblesByClient.set(playbackClient.sessionKey, state);
|
|
3322
|
+
}
|
|
3323
|
+
return { playbackClient, state };
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
function resolveQueuedNextTrackId(state, currentTrackId) {
|
|
3327
|
+
const normalizedCurrent = String(currentTrackId || '').trim();
|
|
3328
|
+
if (!normalizedCurrent) {
|
|
3329
|
+
return '';
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
const queueIds = Array.isArray(state?.queueIds) ? state.queueIds : [];
|
|
3333
|
+
if (queueIds.length === 0) {
|
|
3334
|
+
return '';
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
const index = queueIds.findIndex((id) => String(id || '').trim() === normalizedCurrent);
|
|
3338
|
+
if (index === -1) {
|
|
3339
|
+
return '';
|
|
3340
|
+
}
|
|
3341
|
+
const next = String(queueIds[index + 1] || '').trim();
|
|
3342
|
+
return next;
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
function notePlaybackQueueContext({ accountId, clientName, currentTrackId = '', queueIds = [] }) {
|
|
3346
|
+
const { state } = getPlaybackContinuityState(accountId, clientName);
|
|
3347
|
+
state.queueCurrentId = String(currentTrackId || '').trim();
|
|
3348
|
+
state.queueIds = uniqueNonEmptyValues(Array.isArray(queueIds) ? queueIds : []);
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
function noteRecentScrobble({ accountId, clientName, trackId = '' }) {
|
|
3352
|
+
const now = Date.now();
|
|
3353
|
+
const { state } = getPlaybackContinuityState(accountId, clientName);
|
|
3354
|
+
state.at = now;
|
|
3355
|
+
state.trackId = String(trackId || '').trim();
|
|
3356
|
+
pruneRecentScrobbles(now);
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
function shouldSuppressPlaybackSyncForStreamLoad({ accountId, clientName, trackId }) {
|
|
3360
|
+
const now = Date.now();
|
|
3361
|
+
pruneRecentScrobbles(now);
|
|
3362
|
+
|
|
3363
|
+
const { playbackClient, state: recent } = getPlaybackContinuityState(accountId, clientName);
|
|
3364
|
+
const current = playbackSessions.get(playbackClient.sessionKey);
|
|
3365
|
+
|
|
3366
|
+
const normalizedTrackId = String(trackId || '').trim();
|
|
3367
|
+
if (!normalizedTrackId) {
|
|
3368
|
+
return false;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
const currentTrackId = String(current?.itemId || '').trim();
|
|
3372
|
+
if (current?.state === 'playing' && currentTrackId && normalizedTrackId === currentTrackId) {
|
|
3373
|
+
return false;
|
|
3374
|
+
}
|
|
3375
|
+
|
|
3376
|
+
const queuedCurrentId = String(recent.queueCurrentId || '').trim();
|
|
3377
|
+
if (queuedCurrentId && queuedCurrentId === normalizedTrackId) {
|
|
3378
|
+
return false;
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
const recentTrackId = String(recent.trackId || '').trim();
|
|
3382
|
+
if (
|
|
3383
|
+
recentTrackId &&
|
|
3384
|
+
recentTrackId === currentTrackId &&
|
|
3385
|
+
(now - Number(recent.at || 0)) <= PLAYBACK_CONTINUITY_AFTER_SCROBBLE_MS
|
|
3386
|
+
) {
|
|
3387
|
+
const queuedNextTrackId = resolveQueuedNextTrackId(recent, recentTrackId);
|
|
3388
|
+
if (queuedNextTrackId && queuedNextTrackId === normalizedTrackId) {
|
|
3389
|
+
return false;
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
// No confirmed playback and no trusted continuity signal: treat stream load as preload.
|
|
3394
|
+
if (!current || current.state !== 'playing') {
|
|
3395
|
+
return true;
|
|
3396
|
+
}
|
|
3397
|
+
|
|
3398
|
+
// Current track exists but stale: still require explicit continuity signal to avoid preload flashes.
|
|
3399
|
+
if ((now - Number(current.updatedAt || 0)) > PLAYBACK_IDLE_TIMEOUT_MS) {
|
|
3400
|
+
return true;
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
// Suppress short-lived preloads right after a scrobble signal.
|
|
3404
|
+
if ((now - Number(recent.at || 0)) <= STREAM_PRELOAD_SUPPRESS_AFTER_SCROBBLE_MS) {
|
|
3405
|
+
return true;
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
// Continuity mode: keep current track authoritative unless trusted signal allows switch.
|
|
3409
|
+
return true;
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3164
3412
|
async function syncClientPlaybackState({
|
|
3165
3413
|
accountId,
|
|
3166
3414
|
plexState,
|
|
@@ -5331,6 +5579,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5331
5579
|
updatedAt: now,
|
|
5332
5580
|
});
|
|
5333
5581
|
pruneSavedPlayQueues(now);
|
|
5582
|
+
const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
|
|
5583
|
+
notePlaybackQueueContext({
|
|
5584
|
+
accountId: account.id,
|
|
5585
|
+
clientName,
|
|
5586
|
+
currentTrackId: current,
|
|
5587
|
+
queueIds: ids,
|
|
5588
|
+
});
|
|
5334
5589
|
|
|
5335
5590
|
return sendSubsonicOk(reply);
|
|
5336
5591
|
});
|
|
@@ -5344,9 +5599,11 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5344
5599
|
return;
|
|
5345
5600
|
}
|
|
5346
5601
|
|
|
5602
|
+
const songIds = uniqueNonEmptyValues(getRequestParamValues(request, 'songId'));
|
|
5603
|
+
const genericIds = uniqueNonEmptyValues(getRequestParamValues(request, 'id'));
|
|
5347
5604
|
const ids = uniqueNonEmptyValues([
|
|
5348
|
-
...
|
|
5349
|
-
...
|
|
5605
|
+
...songIds,
|
|
5606
|
+
...genericIds,
|
|
5350
5607
|
]);
|
|
5351
5608
|
|
|
5352
5609
|
if (ids.length === 0) {
|
|
@@ -5377,43 +5634,113 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5377
5634
|
? Math.round(parsedOffset * 1000)
|
|
5378
5635
|
: 0;
|
|
5379
5636
|
const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
|
|
5380
|
-
const primaryTrackId =
|
|
5637
|
+
const primaryTrackId = firstNonEmptyText(
|
|
5638
|
+
[
|
|
5639
|
+
getRequestParam(request, 'songId'),
|
|
5640
|
+
getRequestParam(request, 'id'),
|
|
5641
|
+
songIds[0],
|
|
5642
|
+
ids[0],
|
|
5643
|
+
],
|
|
5644
|
+
'',
|
|
5645
|
+
);
|
|
5646
|
+
const submissionIds = shouldSubmit
|
|
5647
|
+
? uniqueNonEmptyValues([primaryTrackId, ...ids])
|
|
5648
|
+
: [];
|
|
5649
|
+
const isNowPlayingScrobble = !shouldSubmit;
|
|
5381
5650
|
const shouldSyncPlayback =
|
|
5382
5651
|
Boolean(primaryTrackId) &&
|
|
5383
|
-
(
|
|
5384
|
-
|
|
5652
|
+
(
|
|
5653
|
+
hasPlaybackProgress ||
|
|
5654
|
+
hasExplicitState ||
|
|
5655
|
+
isNowPlayingScrobble
|
|
5656
|
+
);
|
|
5657
|
+
const playbackClient = playbackClientContext(account.id, clientName);
|
|
5658
|
+
|
|
5659
|
+
let playbackSyncPromise = null;
|
|
5660
|
+
if (shouldSyncPlayback) {
|
|
5661
|
+
playbackSyncPromise = syncClientPlaybackState({
|
|
5662
|
+
accountId: account.id,
|
|
5663
|
+
plexState,
|
|
5664
|
+
clientName,
|
|
5665
|
+
itemId: primaryTrackId,
|
|
5666
|
+
state: playbackState,
|
|
5667
|
+
positionMs: playbackPositionMs,
|
|
5668
|
+
request,
|
|
5669
|
+
}).catch((error) => {
|
|
5670
|
+
request.log.warn(error, 'Failed to sync playback status to Plex');
|
|
5671
|
+
});
|
|
5672
|
+
}
|
|
5385
5673
|
|
|
5386
5674
|
try {
|
|
5387
|
-
if (
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5675
|
+
if (submissionIds.length > 0) {
|
|
5676
|
+
const [firstId, ...restIds] = submissionIds;
|
|
5677
|
+
await scrobblePlexItem({
|
|
5678
|
+
baseUrl: plexState.baseUrl,
|
|
5679
|
+
plexToken: plexState.plexToken,
|
|
5680
|
+
itemId: firstId,
|
|
5681
|
+
});
|
|
5682
|
+
if (restIds.length > 0) {
|
|
5683
|
+
await Promise.all(
|
|
5684
|
+
restIds.map((id) =>
|
|
5685
|
+
scrobblePlexItem({
|
|
5686
|
+
baseUrl: plexState.baseUrl,
|
|
5687
|
+
plexToken: plexState.plexToken,
|
|
5688
|
+
itemId: id,
|
|
5689
|
+
}),
|
|
5690
|
+
),
|
|
5691
|
+
);
|
|
5692
|
+
}
|
|
5397
5693
|
}
|
|
5398
5694
|
} catch (error) {
|
|
5399
5695
|
request.log.error(error, 'Failed to sync scrobble to Plex');
|
|
5400
5696
|
return sendSubsonicError(reply, 10, 'Failed to scrobble');
|
|
5401
5697
|
}
|
|
5402
5698
|
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5699
|
+
if (playbackSyncPromise) {
|
|
5700
|
+
await playbackSyncPromise;
|
|
5701
|
+
}
|
|
5702
|
+
|
|
5703
|
+
noteRecentScrobble({
|
|
5704
|
+
accountId: account.id,
|
|
5705
|
+
clientName,
|
|
5706
|
+
trackId: primaryTrackId,
|
|
5707
|
+
});
|
|
5708
|
+
|
|
5709
|
+
const allowQueuedPromotion =
|
|
5710
|
+
shouldSubmit &&
|
|
5711
|
+
Boolean(primaryTrackId) &&
|
|
5712
|
+
(
|
|
5713
|
+
(hasExplicitState && playbackState === 'stopped') ||
|
|
5714
|
+
(hasPlaybackProgress && playbackPositionMs > 0)
|
|
5715
|
+
);
|
|
5716
|
+
|
|
5717
|
+
if (allowQueuedPromotion) {
|
|
5718
|
+
const continuity = recentScrobblesByClient.get(playbackClient.sessionKey);
|
|
5719
|
+
const queuedNextTrackId = resolveQueuedNextTrackId(continuity, primaryTrackId);
|
|
5720
|
+
if (queuedNextTrackId) {
|
|
5721
|
+
const current = playbackSessions.get(playbackClient.sessionKey);
|
|
5722
|
+
const currentTrackId = String(current?.itemId || '').trim();
|
|
5723
|
+
const shouldPromoteQueuedNext =
|
|
5724
|
+
!current ||
|
|
5725
|
+
current.state !== 'playing' ||
|
|
5726
|
+
currentTrackId === primaryTrackId;
|
|
5727
|
+
|
|
5728
|
+
if (shouldPromoteQueuedNext) {
|
|
5729
|
+
try {
|
|
5730
|
+
await syncClientPlaybackState({
|
|
5731
|
+
accountId: account.id,
|
|
5732
|
+
plexState,
|
|
5733
|
+
clientName,
|
|
5734
|
+
itemId: queuedNextTrackId,
|
|
5735
|
+
state: 'playing',
|
|
5736
|
+
positionMs: 0,
|
|
5737
|
+
request,
|
|
5738
|
+
});
|
|
5739
|
+
} catch (error) {
|
|
5740
|
+
request.log.warn(error, 'Failed to promote queued next track after scrobble');
|
|
5741
|
+
}
|
|
5742
|
+
}
|
|
5414
5743
|
}
|
|
5415
|
-
} catch (error) {
|
|
5416
|
-
request.log.warn(error, 'Failed to sync playback status to Plex');
|
|
5417
5744
|
}
|
|
5418
5745
|
|
|
5419
5746
|
return sendSubsonicOk(reply);
|
|
@@ -6131,7 +6458,61 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6131
6458
|
return;
|
|
6132
6459
|
}
|
|
6133
6460
|
|
|
6134
|
-
|
|
6461
|
+
const artistId = String(getRequestParam(request, 'id') || '').trim();
|
|
6462
|
+
if (!artistId) {
|
|
6463
|
+
return sendSubsonicError(reply, 70, 'Missing artist id');
|
|
6464
|
+
}
|
|
6465
|
+
|
|
6466
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
6467
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
6468
|
+
if (!plexState) {
|
|
6469
|
+
return;
|
|
6470
|
+
}
|
|
6471
|
+
|
|
6472
|
+
try {
|
|
6473
|
+
let artist = null;
|
|
6474
|
+
try {
|
|
6475
|
+
artist = await getArtist({
|
|
6476
|
+
baseUrl: plexState.baseUrl,
|
|
6477
|
+
plexToken: plexState.plexToken,
|
|
6478
|
+
artistId,
|
|
6479
|
+
});
|
|
6480
|
+
} catch (error) {
|
|
6481
|
+
if (!isPlexNotFoundError(error)) {
|
|
6482
|
+
throw error;
|
|
6483
|
+
}
|
|
6484
|
+
}
|
|
6485
|
+
|
|
6486
|
+
if (!artist) {
|
|
6487
|
+
const fallback = await resolveArtistFromCachedLibrary({
|
|
6488
|
+
accountId: account.id,
|
|
6489
|
+
plexState,
|
|
6490
|
+
request,
|
|
6491
|
+
artistId,
|
|
6492
|
+
});
|
|
6493
|
+
if (fallback?.artist) {
|
|
6494
|
+
artist = fallback.artist;
|
|
6495
|
+
}
|
|
6496
|
+
}
|
|
6497
|
+
|
|
6498
|
+
if (!artist) {
|
|
6499
|
+
return sendSubsonicError(reply, 70, 'Artist not found');
|
|
6500
|
+
}
|
|
6501
|
+
|
|
6502
|
+
const biography = artistBioFromPlex(artist);
|
|
6503
|
+
const musicBrainzId = extractMusicBrainzArtistId(artist);
|
|
6504
|
+
const children = [
|
|
6505
|
+
biography ? node('biography', {}, biography) : '',
|
|
6506
|
+
musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : '',
|
|
6507
|
+
]
|
|
6508
|
+
.filter(Boolean)
|
|
6509
|
+
.join('');
|
|
6510
|
+
|
|
6511
|
+
return sendSubsonicOk(reply, node('artistInfo', {}, children));
|
|
6512
|
+
} catch (error) {
|
|
6513
|
+
request.log.error(error, 'Failed to load artist info');
|
|
6514
|
+
return sendSubsonicError(reply, 10, 'Failed to load artist info');
|
|
6515
|
+
}
|
|
6135
6516
|
});
|
|
6136
6517
|
|
|
6137
6518
|
app.get('/rest/getArtistInfo2.view', async (request, reply) => {
|
|
@@ -6140,7 +6521,61 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6140
6521
|
return;
|
|
6141
6522
|
}
|
|
6142
6523
|
|
|
6143
|
-
|
|
6524
|
+
const artistId = String(getRequestParam(request, 'id') || '').trim();
|
|
6525
|
+
if (!artistId) {
|
|
6526
|
+
return sendSubsonicError(reply, 70, 'Missing artist id');
|
|
6527
|
+
}
|
|
6528
|
+
|
|
6529
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
6530
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
6531
|
+
if (!plexState) {
|
|
6532
|
+
return;
|
|
6533
|
+
}
|
|
6534
|
+
|
|
6535
|
+
try {
|
|
6536
|
+
let artist = null;
|
|
6537
|
+
try {
|
|
6538
|
+
artist = await getArtist({
|
|
6539
|
+
baseUrl: plexState.baseUrl,
|
|
6540
|
+
plexToken: plexState.plexToken,
|
|
6541
|
+
artistId,
|
|
6542
|
+
});
|
|
6543
|
+
} catch (error) {
|
|
6544
|
+
if (!isPlexNotFoundError(error)) {
|
|
6545
|
+
throw error;
|
|
6546
|
+
}
|
|
6547
|
+
}
|
|
6548
|
+
|
|
6549
|
+
if (!artist) {
|
|
6550
|
+
const fallback = await resolveArtistFromCachedLibrary({
|
|
6551
|
+
accountId: account.id,
|
|
6552
|
+
plexState,
|
|
6553
|
+
request,
|
|
6554
|
+
artistId,
|
|
6555
|
+
});
|
|
6556
|
+
if (fallback?.artist) {
|
|
6557
|
+
artist = fallback.artist;
|
|
6558
|
+
}
|
|
6559
|
+
}
|
|
6560
|
+
|
|
6561
|
+
if (!artist) {
|
|
6562
|
+
return sendSubsonicError(reply, 70, 'Artist not found');
|
|
6563
|
+
}
|
|
6564
|
+
|
|
6565
|
+
const biography = artistBioFromPlex(artist);
|
|
6566
|
+
const musicBrainzId = extractMusicBrainzArtistId(artist);
|
|
6567
|
+
const children = [
|
|
6568
|
+
biography ? node('biography', {}, biography) : '',
|
|
6569
|
+
musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : '',
|
|
6570
|
+
]
|
|
6571
|
+
.filter(Boolean)
|
|
6572
|
+
.join('');
|
|
6573
|
+
|
|
6574
|
+
return sendSubsonicOk(reply, node('artistInfo2', {}, children));
|
|
6575
|
+
} catch (error) {
|
|
6576
|
+
request.log.error(error, 'Failed to load artist info2');
|
|
6577
|
+
return sendSubsonicError(reply, 10, 'Failed to load artist info');
|
|
6578
|
+
}
|
|
6144
6579
|
});
|
|
6145
6580
|
|
|
6146
6581
|
app.get('/rest/getAlbum.view', async (request, reply) => {
|
|
@@ -6632,6 +7067,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6632
7067
|
|
|
6633
7068
|
const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
|
|
6634
7069
|
const playbackClient = playbackClientContext(account.id, clientName);
|
|
7070
|
+
const { state: continuityState } = getPlaybackContinuityState(account.id, clientName);
|
|
7071
|
+
const streamPlaybackAuthorityDisabled = Number(continuityState.at || 0) > 0;
|
|
7072
|
+
const suppressStreamLoadPlaybackSync = shouldSuppressPlaybackSyncForStreamLoad({
|
|
7073
|
+
accountId: account.id,
|
|
7074
|
+
clientName,
|
|
7075
|
+
trackId,
|
|
7076
|
+
}) || streamPlaybackAuthorityDisabled;
|
|
6635
7077
|
const offsetRaw = getRequestParam(request, 'timeOffset');
|
|
6636
7078
|
const offsetSeconds = Number.parseFloat(offsetRaw);
|
|
6637
7079
|
const offsetMs = Number.isFinite(offsetSeconds) && offsetSeconds > 0 ? Math.round(offsetSeconds * 1000) : 0;
|
|
@@ -6655,23 +7097,71 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6655
7097
|
progressTimer = null;
|
|
6656
7098
|
}
|
|
6657
7099
|
};
|
|
7100
|
+
let streamClosed = false;
|
|
7101
|
+
let streamTrackingStarted = false;
|
|
7102
|
+
let suppressedPromoteTimer = null;
|
|
7103
|
+
const clearSuppressedPromoteTimer = () => {
|
|
7104
|
+
if (suppressedPromoteTimer) {
|
|
7105
|
+
clearTimeout(suppressedPromoteTimer);
|
|
7106
|
+
suppressedPromoteTimer = null;
|
|
7107
|
+
}
|
|
7108
|
+
};
|
|
6658
7109
|
|
|
6659
|
-
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
7110
|
+
const startStreamPlaybackTracking = async (positionMs) => {
|
|
7111
|
+
if (streamTrackingStarted || streamClosed) {
|
|
7112
|
+
return;
|
|
7113
|
+
}
|
|
7114
|
+
streamTrackingStarted = true;
|
|
7115
|
+
|
|
7116
|
+
try {
|
|
7117
|
+
await syncClientPlaybackState({
|
|
7118
|
+
accountId: account.id,
|
|
7119
|
+
plexState,
|
|
7120
|
+
clientName,
|
|
7121
|
+
itemId: trackId,
|
|
7122
|
+
state: 'playing',
|
|
7123
|
+
positionMs,
|
|
7124
|
+
durationMs: trackDurationMs,
|
|
7125
|
+
request,
|
|
7126
|
+
});
|
|
7127
|
+
} catch (error) {
|
|
7128
|
+
request.log.warn(error, 'Failed to sync stream-start playback status to Plex');
|
|
7129
|
+
}
|
|
7130
|
+
|
|
7131
|
+
progressTimer = setInterval(async () => {
|
|
7132
|
+
if (progressSyncInFlight) {
|
|
7133
|
+
return;
|
|
7134
|
+
}
|
|
7135
|
+
|
|
7136
|
+
const current = playbackSessions.get(playbackClient.sessionKey);
|
|
7137
|
+
if (!current || current.itemId !== String(trackId) || current.state !== 'playing') {
|
|
7138
|
+
return;
|
|
7139
|
+
}
|
|
7140
|
+
|
|
7141
|
+
progressSyncInFlight = true;
|
|
7142
|
+
try {
|
|
7143
|
+
await syncClientPlaybackState({
|
|
7144
|
+
accountId: account.id,
|
|
7145
|
+
plexState,
|
|
7146
|
+
clientName,
|
|
7147
|
+
itemId: trackId,
|
|
7148
|
+
state: 'playing',
|
|
7149
|
+
positionMs: estimatePlaybackPositionMs(),
|
|
7150
|
+
durationMs: trackDurationMs,
|
|
7151
|
+
request,
|
|
7152
|
+
});
|
|
7153
|
+
} catch (error) {
|
|
7154
|
+
request.log.warn(error, 'Failed to sync stream progress playback status to Plex');
|
|
7155
|
+
} finally {
|
|
7156
|
+
progressSyncInFlight = false;
|
|
7157
|
+
}
|
|
7158
|
+
}, STREAM_PROGRESS_HEARTBEAT_MS);
|
|
7159
|
+
|
|
7160
|
+
if (typeof progressTimer.unref === 'function') {
|
|
7161
|
+
progressTimer.unref();
|
|
7162
|
+
}
|
|
7163
|
+
};
|
|
6673
7164
|
|
|
6674
|
-
let streamClosed = false;
|
|
6675
7165
|
const handleStreamClosed = () => {
|
|
6676
7166
|
if (streamClosed) {
|
|
6677
7167
|
return;
|
|
@@ -6680,6 +7170,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6680
7170
|
|
|
6681
7171
|
const closedAt = Date.now();
|
|
6682
7172
|
clearProgressTimer();
|
|
7173
|
+
clearSuppressedPromoteTimer();
|
|
6683
7174
|
const estimatedAtClose = estimatePlaybackPositionMs(closedAt);
|
|
6684
7175
|
let currentAtClose = playbackSessions.get(playbackClient.sessionKey);
|
|
6685
7176
|
if (currentAtClose && currentAtClose.itemId === String(trackId) && currentAtClose.state === 'playing') {
|
|
@@ -6761,51 +7252,71 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6761
7252
|
timer.unref();
|
|
6762
7253
|
}
|
|
6763
7254
|
};
|
|
7255
|
+
if (!streamPlaybackAuthorityDisabled) {
|
|
7256
|
+
reply.raw.once('close', handleStreamClosed);
|
|
7257
|
+
reply.raw.once('finish', handleStreamClosed);
|
|
7258
|
+
}
|
|
6764
7259
|
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
return;
|
|
6771
|
-
}
|
|
7260
|
+
if (suppressStreamLoadPlaybackSync) {
|
|
7261
|
+
request.log.debug(
|
|
7262
|
+
{ trackId, clientName, streamPlaybackAuthorityDisabled },
|
|
7263
|
+
'Suppressing immediate stream-start playback sync; awaiting continuity/persistence',
|
|
7264
|
+
);
|
|
6772
7265
|
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
7266
|
+
suppressedPromoteTimer = setTimeout(async () => {
|
|
7267
|
+
if (streamClosed || streamTrackingStarted) {
|
|
7268
|
+
return;
|
|
7269
|
+
}
|
|
6777
7270
|
|
|
6778
|
-
|
|
6779
|
-
try {
|
|
6780
|
-
await syncClientPlaybackState({
|
|
7271
|
+
const stillSuppressed = shouldSuppressPlaybackSyncForStreamLoad({
|
|
6781
7272
|
accountId: account.id,
|
|
6782
|
-
plexState,
|
|
6783
7273
|
clientName,
|
|
6784
|
-
|
|
6785
|
-
state: 'playing',
|
|
6786
|
-
positionMs: estimatePlaybackPositionMs(),
|
|
6787
|
-
durationMs: trackDurationMs,
|
|
6788
|
-
request,
|
|
7274
|
+
trackId,
|
|
6789
7275
|
});
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
}
|
|
6795
|
-
}, STREAM_PROGRESS_HEARTBEAT_MS);
|
|
7276
|
+
if (!stillSuppressed) {
|
|
7277
|
+
await startStreamPlaybackTracking(estimatePlaybackPositionMs());
|
|
7278
|
+
return;
|
|
7279
|
+
}
|
|
6796
7280
|
|
|
6797
|
-
|
|
6798
|
-
|
|
7281
|
+
const hasScrobbleDrivenContinuity = Number(continuityState.at || 0) > 0;
|
|
7282
|
+
if (hasScrobbleDrivenContinuity) {
|
|
7283
|
+
request.log.debug(
|
|
7284
|
+
{ trackId, clientName },
|
|
7285
|
+
'Keeping suppressed stream pending explicit scrobble/queue continuity',
|
|
7286
|
+
);
|
|
7287
|
+
return;
|
|
7288
|
+
}
|
|
7289
|
+
|
|
7290
|
+
// Fallback only for clients that never provide continuity signals.
|
|
7291
|
+
await startStreamPlaybackTracking(estimatePlaybackPositionMs());
|
|
7292
|
+
}, STREAM_SUPPRESSED_PROMOTE_DELAY_MS);
|
|
7293
|
+
|
|
7294
|
+
if (typeof suppressedPromoteTimer.unref === 'function') {
|
|
7295
|
+
suppressedPromoteTimer.unref();
|
|
7296
|
+
}
|
|
7297
|
+
} else {
|
|
7298
|
+
await startStreamPlaybackTracking(offsetMs);
|
|
6799
7299
|
}
|
|
6800
7300
|
|
|
6801
7301
|
const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
|
|
6802
7302
|
const rangeHeader = request.headers.range;
|
|
7303
|
+
const upstreamController = new AbortController();
|
|
7304
|
+
const abortUpstreamOnDisconnect = () => {
|
|
7305
|
+
if (!upstreamController.signal.aborted) {
|
|
7306
|
+
upstreamController.abort();
|
|
7307
|
+
}
|
|
7308
|
+
};
|
|
7309
|
+
request.raw.once('aborted', abortUpstreamOnDisconnect);
|
|
7310
|
+
request.raw.once('close', abortUpstreamOnDisconnect);
|
|
7311
|
+
reply.raw.once('close', abortUpstreamOnDisconnect);
|
|
7312
|
+
|
|
6803
7313
|
const upstream = await fetchWithRetry({
|
|
6804
7314
|
url: streamUrl,
|
|
6805
7315
|
options: {
|
|
6806
7316
|
headers: {
|
|
6807
7317
|
...(rangeHeader ? { Range: rangeHeader } : {}),
|
|
6808
7318
|
},
|
|
7319
|
+
signal: upstreamController.signal,
|
|
6809
7320
|
},
|
|
6810
7321
|
request,
|
|
6811
7322
|
context: 'track stream proxy',
|
|
@@ -6815,6 +7326,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6815
7326
|
|
|
6816
7327
|
if (!upstream.ok || !upstream.body) {
|
|
6817
7328
|
clearProgressTimer();
|
|
7329
|
+
clearSuppressedPromoteTimer();
|
|
6818
7330
|
request.log.warn({ status: upstream.status }, 'Failed to proxy track stream');
|
|
6819
7331
|
return sendSubsonicError(reply, 70, 'Track stream unavailable');
|
|
6820
7332
|
}
|
|
@@ -6835,8 +7347,34 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6835
7347
|
}
|
|
6836
7348
|
}
|
|
6837
7349
|
|
|
6838
|
-
|
|
7350
|
+
const proxiedBody = Readable.fromWeb(upstream.body);
|
|
7351
|
+
const responseBody = new PassThrough();
|
|
7352
|
+
|
|
7353
|
+
proxiedBody.on('error', (streamError) => {
|
|
7354
|
+
if (isAbortError(streamError) || isUpstreamTerminationError(streamError) || isClientDisconnected(request, reply)) {
|
|
7355
|
+
request.log.debug({ trackId, clientName }, 'Ignoring expected stream termination');
|
|
7356
|
+
responseBody.end();
|
|
7357
|
+
return;
|
|
7358
|
+
}
|
|
7359
|
+
request.log.warn(streamError, 'Upstream stream error while proxying track');
|
|
7360
|
+
responseBody.destroy(streamError);
|
|
7361
|
+
});
|
|
7362
|
+
|
|
7363
|
+
responseBody.on('error', (streamError) => {
|
|
7364
|
+
if (isAbortError(streamError) || isUpstreamTerminationError(streamError) || isClientDisconnected(request, reply)) {
|
|
7365
|
+
request.log.debug({ trackId, clientName }, 'Ignoring expected response stream termination');
|
|
7366
|
+
return;
|
|
7367
|
+
}
|
|
7368
|
+
request.log.warn(streamError, 'Response stream error while proxying track');
|
|
7369
|
+
});
|
|
7370
|
+
|
|
7371
|
+
proxiedBody.pipe(responseBody);
|
|
7372
|
+
return reply.send(responseBody);
|
|
6839
7373
|
} catch (error) {
|
|
7374
|
+
if (isAbortError(error) || isUpstreamTerminationError(error) || isClientDisconnected(request, reply)) {
|
|
7375
|
+
request.log.debug({ trackId }, 'Ignoring expected stream disconnect');
|
|
7376
|
+
return;
|
|
7377
|
+
}
|
|
6840
7378
|
request.log.error(error, 'Failed to proxy stream');
|
|
6841
7379
|
return sendSubsonicError(reply, 10, 'Stream proxy failed');
|
|
6842
7380
|
}
|
package/src/subsonic-xml.js
CHANGED
|
@@ -321,6 +321,11 @@ function nodeToJson(node) {
|
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
const keys = Object.keys(out);
|
|
325
|
+
if (keys.length === 1 && keys[0] === 'value') {
|
|
326
|
+
return out.value;
|
|
327
|
+
}
|
|
328
|
+
|
|
324
329
|
if (node.name === 'openSubsonicExtensions') {
|
|
325
330
|
const extensions = out.openSubsonicExtension;
|
|
326
331
|
if (Array.isArray(extensions)) {
|