plexsonic 0.1.6 → 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 +398 -73
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';
|
|
@@ -1799,6 +1799,35 @@ function isAbortError(error) {
|
|
|
1799
1799
|
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
|
|
1800
1800
|
}
|
|
1801
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
|
+
|
|
1802
1831
|
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
1803
1832
|
|
|
1804
1833
|
function waitMs(ms) {
|
|
@@ -2451,12 +2480,16 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2451
2480
|
});
|
|
2452
2481
|
|
|
2453
2482
|
const playbackSessions = new Map();
|
|
2483
|
+
const recentScrobblesByClient = new Map();
|
|
2454
2484
|
const savedPlayQueues = new Map();
|
|
2455
2485
|
const PLAYBACK_RECONCILE_INTERVAL_MS = 15000;
|
|
2456
2486
|
const PLAYBACK_IDLE_TIMEOUT_MS = 120000;
|
|
2457
2487
|
const STREAM_DISCONNECT_STOP_DELAY_MS = 4000;
|
|
2458
2488
|
const PLAYBACK_MAX_DISCONNECT_WAIT_MS = 30 * 60 * 1000;
|
|
2459
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;
|
|
2460
2493
|
const PLAY_QUEUE_IDLE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
2461
2494
|
const activeSearchRequests = new Map();
|
|
2462
2495
|
const searchBrowseCache = new Map();
|
|
@@ -3266,6 +3299,116 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3266
3299
|
};
|
|
3267
3300
|
}
|
|
3268
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
|
+
|
|
3269
3412
|
async function syncClientPlaybackState({
|
|
3270
3413
|
accountId,
|
|
3271
3414
|
plexState,
|
|
@@ -5436,6 +5579,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5436
5579
|
updatedAt: now,
|
|
5437
5580
|
});
|
|
5438
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
|
+
});
|
|
5439
5589
|
|
|
5440
5590
|
return sendSubsonicOk(reply);
|
|
5441
5591
|
});
|
|
@@ -5449,9 +5599,11 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5449
5599
|
return;
|
|
5450
5600
|
}
|
|
5451
5601
|
|
|
5602
|
+
const songIds = uniqueNonEmptyValues(getRequestParamValues(request, 'songId'));
|
|
5603
|
+
const genericIds = uniqueNonEmptyValues(getRequestParamValues(request, 'id'));
|
|
5452
5604
|
const ids = uniqueNonEmptyValues([
|
|
5453
|
-
...
|
|
5454
|
-
...
|
|
5605
|
+
...songIds,
|
|
5606
|
+
...genericIds,
|
|
5455
5607
|
]);
|
|
5456
5608
|
|
|
5457
5609
|
if (ids.length === 0) {
|
|
@@ -5482,43 +5634,113 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5482
5634
|
? Math.round(parsedOffset * 1000)
|
|
5483
5635
|
: 0;
|
|
5484
5636
|
const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
|
|
5485
|
-
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;
|
|
5486
5650
|
const shouldSyncPlayback =
|
|
5487
5651
|
Boolean(primaryTrackId) &&
|
|
5488
|
-
(
|
|
5489
|
-
|
|
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
|
+
}
|
|
5490
5673
|
|
|
5491
5674
|
try {
|
|
5492
|
-
if (
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
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
|
+
}
|
|
5502
5693
|
}
|
|
5503
5694
|
} catch (error) {
|
|
5504
5695
|
request.log.error(error, 'Failed to sync scrobble to Plex');
|
|
5505
5696
|
return sendSubsonicError(reply, 10, 'Failed to scrobble');
|
|
5506
5697
|
}
|
|
5507
5698
|
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
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
|
+
}
|
|
5519
5743
|
}
|
|
5520
|
-
} catch (error) {
|
|
5521
|
-
request.log.warn(error, 'Failed to sync playback status to Plex');
|
|
5522
5744
|
}
|
|
5523
5745
|
|
|
5524
5746
|
return sendSubsonicOk(reply);
|
|
@@ -6845,6 +7067,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6845
7067
|
|
|
6846
7068
|
const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
|
|
6847
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;
|
|
6848
7077
|
const offsetRaw = getRequestParam(request, 'timeOffset');
|
|
6849
7078
|
const offsetSeconds = Number.parseFloat(offsetRaw);
|
|
6850
7079
|
const offsetMs = Number.isFinite(offsetSeconds) && offsetSeconds > 0 ? Math.round(offsetSeconds * 1000) : 0;
|
|
@@ -6868,23 +7097,71 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6868
7097
|
progressTimer = null;
|
|
6869
7098
|
}
|
|
6870
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
|
+
};
|
|
6871
7109
|
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
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
|
+
};
|
|
6886
7164
|
|
|
6887
|
-
let streamClosed = false;
|
|
6888
7165
|
const handleStreamClosed = () => {
|
|
6889
7166
|
if (streamClosed) {
|
|
6890
7167
|
return;
|
|
@@ -6893,6 +7170,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6893
7170
|
|
|
6894
7171
|
const closedAt = Date.now();
|
|
6895
7172
|
clearProgressTimer();
|
|
7173
|
+
clearSuppressedPromoteTimer();
|
|
6896
7174
|
const estimatedAtClose = estimatePlaybackPositionMs(closedAt);
|
|
6897
7175
|
let currentAtClose = playbackSessions.get(playbackClient.sessionKey);
|
|
6898
7176
|
if (currentAtClose && currentAtClose.itemId === String(trackId) && currentAtClose.state === 'playing') {
|
|
@@ -6974,51 +7252,71 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6974
7252
|
timer.unref();
|
|
6975
7253
|
}
|
|
6976
7254
|
};
|
|
7255
|
+
if (!streamPlaybackAuthorityDisabled) {
|
|
7256
|
+
reply.raw.once('close', handleStreamClosed);
|
|
7257
|
+
reply.raw.once('finish', handleStreamClosed);
|
|
7258
|
+
}
|
|
6977
7259
|
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
return;
|
|
6984
|
-
}
|
|
7260
|
+
if (suppressStreamLoadPlaybackSync) {
|
|
7261
|
+
request.log.debug(
|
|
7262
|
+
{ trackId, clientName, streamPlaybackAuthorityDisabled },
|
|
7263
|
+
'Suppressing immediate stream-start playback sync; awaiting continuity/persistence',
|
|
7264
|
+
);
|
|
6985
7265
|
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
7266
|
+
suppressedPromoteTimer = setTimeout(async () => {
|
|
7267
|
+
if (streamClosed || streamTrackingStarted) {
|
|
7268
|
+
return;
|
|
7269
|
+
}
|
|
6990
7270
|
|
|
6991
|
-
|
|
6992
|
-
try {
|
|
6993
|
-
await syncClientPlaybackState({
|
|
7271
|
+
const stillSuppressed = shouldSuppressPlaybackSyncForStreamLoad({
|
|
6994
7272
|
accountId: account.id,
|
|
6995
|
-
plexState,
|
|
6996
7273
|
clientName,
|
|
6997
|
-
|
|
6998
|
-
state: 'playing',
|
|
6999
|
-
positionMs: estimatePlaybackPositionMs(),
|
|
7000
|
-
durationMs: trackDurationMs,
|
|
7001
|
-
request,
|
|
7274
|
+
trackId,
|
|
7002
7275
|
});
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7276
|
+
if (!stillSuppressed) {
|
|
7277
|
+
await startStreamPlaybackTracking(estimatePlaybackPositionMs());
|
|
7278
|
+
return;
|
|
7279
|
+
}
|
|
7280
|
+
|
|
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
|
+
}
|
|
7009
7289
|
|
|
7010
|
-
|
|
7011
|
-
|
|
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);
|
|
7012
7299
|
}
|
|
7013
7300
|
|
|
7014
7301
|
const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
|
|
7015
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
|
+
|
|
7016
7313
|
const upstream = await fetchWithRetry({
|
|
7017
7314
|
url: streamUrl,
|
|
7018
7315
|
options: {
|
|
7019
7316
|
headers: {
|
|
7020
7317
|
...(rangeHeader ? { Range: rangeHeader } : {}),
|
|
7021
7318
|
},
|
|
7319
|
+
signal: upstreamController.signal,
|
|
7022
7320
|
},
|
|
7023
7321
|
request,
|
|
7024
7322
|
context: 'track stream proxy',
|
|
@@ -7028,6 +7326,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
7028
7326
|
|
|
7029
7327
|
if (!upstream.ok || !upstream.body) {
|
|
7030
7328
|
clearProgressTimer();
|
|
7329
|
+
clearSuppressedPromoteTimer();
|
|
7031
7330
|
request.log.warn({ status: upstream.status }, 'Failed to proxy track stream');
|
|
7032
7331
|
return sendSubsonicError(reply, 70, 'Track stream unavailable');
|
|
7033
7332
|
}
|
|
@@ -7048,8 +7347,34 @@ export async function buildServer(config = loadConfig()) {
|
|
|
7048
7347
|
}
|
|
7049
7348
|
}
|
|
7050
7349
|
|
|
7051
|
-
|
|
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);
|
|
7052
7373
|
} catch (error) {
|
|
7374
|
+
if (isAbortError(error) || isUpstreamTerminationError(error) || isClientDisconnected(request, reply)) {
|
|
7375
|
+
request.log.debug({ trackId }, 'Ignoring expected stream disconnect');
|
|
7376
|
+
return;
|
|
7377
|
+
}
|
|
7053
7378
|
request.log.error(error, 'Failed to proxy stream');
|
|
7054
7379
|
return sendSubsonicError(reply, 10, 'Stream proxy failed');
|
|
7055
7380
|
}
|