plexsonic 0.1.6 → 0.1.8
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 +460 -74
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,
|
|
@@ -3348,6 +3491,49 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3348
3491
|
});
|
|
3349
3492
|
}
|
|
3350
3493
|
|
|
3494
|
+
function updatePlaybackSessionEstimateFromStream({
|
|
3495
|
+
accountId,
|
|
3496
|
+
clientName,
|
|
3497
|
+
trackId,
|
|
3498
|
+
durationMs = null,
|
|
3499
|
+
positionMs = 0,
|
|
3500
|
+
}) {
|
|
3501
|
+
const playbackClient = playbackClientContext(accountId, clientName);
|
|
3502
|
+
const current = playbackSessions.get(playbackClient.sessionKey);
|
|
3503
|
+
if (!current || current.state !== 'playing') {
|
|
3504
|
+
return false;
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
const normalizedTrackId = String(trackId || '').trim();
|
|
3508
|
+
if (!normalizedTrackId || String(current.itemId || '').trim() !== normalizedTrackId) {
|
|
3509
|
+
return false;
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
const now = Date.now();
|
|
3513
|
+
const normalizedDuration = Number.isFinite(durationMs) && durationMs > 0
|
|
3514
|
+
? Math.max(0, Math.trunc(durationMs))
|
|
3515
|
+
: Number.isFinite(current.durationMs) && current.durationMs > 0
|
|
3516
|
+
? Math.max(0, Math.trunc(current.durationMs))
|
|
3517
|
+
: null;
|
|
3518
|
+
const normalizedPosition = Number.isFinite(positionMs) && positionMs >= 0
|
|
3519
|
+
? Math.max(0, Math.trunc(positionMs))
|
|
3520
|
+
: 0;
|
|
3521
|
+
const basePosition = Math.max(Number(current.positionMs || 0), normalizedPosition);
|
|
3522
|
+
const estimatedStopAt = normalizedDuration != null
|
|
3523
|
+
? now + Math.max(0, normalizedDuration - basePosition)
|
|
3524
|
+
: current.estimatedStopAt;
|
|
3525
|
+
|
|
3526
|
+
playbackSessions.set(playbackClient.sessionKey, {
|
|
3527
|
+
...current,
|
|
3528
|
+
durationMs: normalizedDuration,
|
|
3529
|
+
positionMs: basePosition,
|
|
3530
|
+
estimatedStopAt,
|
|
3531
|
+
updatedAt: now,
|
|
3532
|
+
});
|
|
3533
|
+
|
|
3534
|
+
return true;
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3351
3537
|
const playbackMaintenanceTimer = setInterval(async () => {
|
|
3352
3538
|
const now = Date.now();
|
|
3353
3539
|
const sessions = [...playbackSessions.entries()];
|
|
@@ -3358,8 +3544,16 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3358
3544
|
continue;
|
|
3359
3545
|
}
|
|
3360
3546
|
|
|
3361
|
-
|
|
3547
|
+
const estimatedStopReached = (
|
|
3548
|
+
session.state === 'playing' &&
|
|
3549
|
+
Number.isFinite(session.estimatedStopAt) &&
|
|
3550
|
+
session.estimatedStopAt <= now
|
|
3551
|
+
);
|
|
3552
|
+
const idleExpired = now - Number(session.updatedAt || 0) > PLAYBACK_IDLE_TIMEOUT_MS;
|
|
3553
|
+
|
|
3554
|
+
if (estimatedStopReached || idleExpired) {
|
|
3362
3555
|
if (
|
|
3556
|
+
!estimatedStopReached &&
|
|
3363
3557
|
session.state === 'playing' &&
|
|
3364
3558
|
Number.isFinite(session.estimatedStopAt) &&
|
|
3365
3559
|
session.estimatedStopAt > now
|
|
@@ -5436,6 +5630,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5436
5630
|
updatedAt: now,
|
|
5437
5631
|
});
|
|
5438
5632
|
pruneSavedPlayQueues(now);
|
|
5633
|
+
const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
|
|
5634
|
+
notePlaybackQueueContext({
|
|
5635
|
+
accountId: account.id,
|
|
5636
|
+
clientName,
|
|
5637
|
+
currentTrackId: current,
|
|
5638
|
+
queueIds: ids,
|
|
5639
|
+
});
|
|
5439
5640
|
|
|
5440
5641
|
return sendSubsonicOk(reply);
|
|
5441
5642
|
});
|
|
@@ -5449,9 +5650,11 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5449
5650
|
return;
|
|
5450
5651
|
}
|
|
5451
5652
|
|
|
5653
|
+
const songIds = uniqueNonEmptyValues(getRequestParamValues(request, 'songId'));
|
|
5654
|
+
const genericIds = uniqueNonEmptyValues(getRequestParamValues(request, 'id'));
|
|
5452
5655
|
const ids = uniqueNonEmptyValues([
|
|
5453
|
-
...
|
|
5454
|
-
...
|
|
5656
|
+
...songIds,
|
|
5657
|
+
...genericIds,
|
|
5455
5658
|
]);
|
|
5456
5659
|
|
|
5457
5660
|
if (ids.length === 0) {
|
|
@@ -5482,43 +5685,113 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5482
5685
|
? Math.round(parsedOffset * 1000)
|
|
5483
5686
|
: 0;
|
|
5484
5687
|
const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
|
|
5485
|
-
const primaryTrackId =
|
|
5688
|
+
const primaryTrackId = firstNonEmptyText(
|
|
5689
|
+
[
|
|
5690
|
+
getRequestParam(request, 'songId'),
|
|
5691
|
+
getRequestParam(request, 'id'),
|
|
5692
|
+
songIds[0],
|
|
5693
|
+
ids[0],
|
|
5694
|
+
],
|
|
5695
|
+
'',
|
|
5696
|
+
);
|
|
5697
|
+
const submissionIds = shouldSubmit
|
|
5698
|
+
? uniqueNonEmptyValues([primaryTrackId, ...ids])
|
|
5699
|
+
: [];
|
|
5700
|
+
const isNowPlayingScrobble = !shouldSubmit;
|
|
5486
5701
|
const shouldSyncPlayback =
|
|
5487
5702
|
Boolean(primaryTrackId) &&
|
|
5488
|
-
(
|
|
5489
|
-
|
|
5703
|
+
(
|
|
5704
|
+
hasPlaybackProgress ||
|
|
5705
|
+
hasExplicitState ||
|
|
5706
|
+
isNowPlayingScrobble
|
|
5707
|
+
);
|
|
5708
|
+
const playbackClient = playbackClientContext(account.id, clientName);
|
|
5709
|
+
|
|
5710
|
+
let playbackSyncPromise = null;
|
|
5711
|
+
if (shouldSyncPlayback) {
|
|
5712
|
+
playbackSyncPromise = syncClientPlaybackState({
|
|
5713
|
+
accountId: account.id,
|
|
5714
|
+
plexState,
|
|
5715
|
+
clientName,
|
|
5716
|
+
itemId: primaryTrackId,
|
|
5717
|
+
state: playbackState,
|
|
5718
|
+
positionMs: playbackPositionMs,
|
|
5719
|
+
request,
|
|
5720
|
+
}).catch((error) => {
|
|
5721
|
+
request.log.warn(error, 'Failed to sync playback status to Plex');
|
|
5722
|
+
});
|
|
5723
|
+
}
|
|
5490
5724
|
|
|
5491
5725
|
try {
|
|
5492
|
-
if (
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5726
|
+
if (submissionIds.length > 0) {
|
|
5727
|
+
const [firstId, ...restIds] = submissionIds;
|
|
5728
|
+
await scrobblePlexItem({
|
|
5729
|
+
baseUrl: plexState.baseUrl,
|
|
5730
|
+
plexToken: plexState.plexToken,
|
|
5731
|
+
itemId: firstId,
|
|
5732
|
+
});
|
|
5733
|
+
if (restIds.length > 0) {
|
|
5734
|
+
await Promise.all(
|
|
5735
|
+
restIds.map((id) =>
|
|
5736
|
+
scrobblePlexItem({
|
|
5737
|
+
baseUrl: plexState.baseUrl,
|
|
5738
|
+
plexToken: plexState.plexToken,
|
|
5739
|
+
itemId: id,
|
|
5740
|
+
}),
|
|
5741
|
+
),
|
|
5742
|
+
);
|
|
5743
|
+
}
|
|
5502
5744
|
}
|
|
5503
5745
|
} catch (error) {
|
|
5504
5746
|
request.log.error(error, 'Failed to sync scrobble to Plex');
|
|
5505
5747
|
return sendSubsonicError(reply, 10, 'Failed to scrobble');
|
|
5506
5748
|
}
|
|
5507
5749
|
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5750
|
+
if (playbackSyncPromise) {
|
|
5751
|
+
await playbackSyncPromise;
|
|
5752
|
+
}
|
|
5753
|
+
|
|
5754
|
+
noteRecentScrobble({
|
|
5755
|
+
accountId: account.id,
|
|
5756
|
+
clientName,
|
|
5757
|
+
trackId: primaryTrackId,
|
|
5758
|
+
});
|
|
5759
|
+
|
|
5760
|
+
const allowQueuedPromotion =
|
|
5761
|
+
shouldSubmit &&
|
|
5762
|
+
Boolean(primaryTrackId) &&
|
|
5763
|
+
(
|
|
5764
|
+
(hasExplicitState && playbackState === 'stopped') ||
|
|
5765
|
+
(hasPlaybackProgress && playbackPositionMs > 0)
|
|
5766
|
+
);
|
|
5767
|
+
|
|
5768
|
+
if (allowQueuedPromotion) {
|
|
5769
|
+
const continuity = recentScrobblesByClient.get(playbackClient.sessionKey);
|
|
5770
|
+
const queuedNextTrackId = resolveQueuedNextTrackId(continuity, primaryTrackId);
|
|
5771
|
+
if (queuedNextTrackId) {
|
|
5772
|
+
const current = playbackSessions.get(playbackClient.sessionKey);
|
|
5773
|
+
const currentTrackId = String(current?.itemId || '').trim();
|
|
5774
|
+
const shouldPromoteQueuedNext =
|
|
5775
|
+
!current ||
|
|
5776
|
+
current.state !== 'playing' ||
|
|
5777
|
+
currentTrackId === primaryTrackId;
|
|
5778
|
+
|
|
5779
|
+
if (shouldPromoteQueuedNext) {
|
|
5780
|
+
try {
|
|
5781
|
+
await syncClientPlaybackState({
|
|
5782
|
+
accountId: account.id,
|
|
5783
|
+
plexState,
|
|
5784
|
+
clientName,
|
|
5785
|
+
itemId: queuedNextTrackId,
|
|
5786
|
+
state: 'playing',
|
|
5787
|
+
positionMs: 0,
|
|
5788
|
+
request,
|
|
5789
|
+
});
|
|
5790
|
+
} catch (error) {
|
|
5791
|
+
request.log.warn(error, 'Failed to promote queued next track after scrobble');
|
|
5792
|
+
}
|
|
5793
|
+
}
|
|
5519
5794
|
}
|
|
5520
|
-
} catch (error) {
|
|
5521
|
-
request.log.warn(error, 'Failed to sync playback status to Plex');
|
|
5522
5795
|
}
|
|
5523
5796
|
|
|
5524
5797
|
return sendSubsonicOk(reply);
|
|
@@ -6845,12 +7118,29 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6845
7118
|
|
|
6846
7119
|
const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
|
|
6847
7120
|
const playbackClient = playbackClientContext(account.id, clientName);
|
|
7121
|
+
const { state: continuityState } = getPlaybackContinuityState(account.id, clientName);
|
|
7122
|
+
const streamPlaybackAuthorityDisabled = Number(continuityState.at || 0) > 0;
|
|
7123
|
+
const suppressStreamLoadPlaybackSync = shouldSuppressPlaybackSyncForStreamLoad({
|
|
7124
|
+
accountId: account.id,
|
|
7125
|
+
clientName,
|
|
7126
|
+
trackId,
|
|
7127
|
+
}) || streamPlaybackAuthorityDisabled;
|
|
6848
7128
|
const offsetRaw = getRequestParam(request, 'timeOffset');
|
|
6849
7129
|
const offsetSeconds = Number.parseFloat(offsetRaw);
|
|
6850
7130
|
const offsetMs = Number.isFinite(offsetSeconds) && offsetSeconds > 0 ? Math.round(offsetSeconds * 1000) : 0;
|
|
6851
7131
|
const trackDurationMs = parseNonNegativeInt(track.duration, 0);
|
|
6852
7132
|
const playbackStartAt = Date.now();
|
|
6853
7133
|
|
|
7134
|
+
if (streamPlaybackAuthorityDisabled) {
|
|
7135
|
+
updatePlaybackSessionEstimateFromStream({
|
|
7136
|
+
accountId: account.id,
|
|
7137
|
+
clientName,
|
|
7138
|
+
trackId,
|
|
7139
|
+
durationMs: trackDurationMs > 0 ? trackDurationMs : null,
|
|
7140
|
+
positionMs: offsetMs,
|
|
7141
|
+
});
|
|
7142
|
+
}
|
|
7143
|
+
|
|
6854
7144
|
const estimatePlaybackPositionMs = (nowMs = Date.now()) => {
|
|
6855
7145
|
const elapsedMs = Math.max(0, nowMs - playbackStartAt);
|
|
6856
7146
|
const estimated = offsetMs + elapsedMs;
|
|
@@ -6868,23 +7158,71 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6868
7158
|
progressTimer = null;
|
|
6869
7159
|
}
|
|
6870
7160
|
};
|
|
7161
|
+
let streamClosed = false;
|
|
7162
|
+
let streamTrackingStarted = false;
|
|
7163
|
+
let suppressedPromoteTimer = null;
|
|
7164
|
+
const clearSuppressedPromoteTimer = () => {
|
|
7165
|
+
if (suppressedPromoteTimer) {
|
|
7166
|
+
clearTimeout(suppressedPromoteTimer);
|
|
7167
|
+
suppressedPromoteTimer = null;
|
|
7168
|
+
}
|
|
7169
|
+
};
|
|
6871
7170
|
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
7171
|
+
const startStreamPlaybackTracking = async (positionMs) => {
|
|
7172
|
+
if (streamTrackingStarted || streamClosed) {
|
|
7173
|
+
return;
|
|
7174
|
+
}
|
|
7175
|
+
streamTrackingStarted = true;
|
|
7176
|
+
|
|
7177
|
+
try {
|
|
7178
|
+
await syncClientPlaybackState({
|
|
7179
|
+
accountId: account.id,
|
|
7180
|
+
plexState,
|
|
7181
|
+
clientName,
|
|
7182
|
+
itemId: trackId,
|
|
7183
|
+
state: 'playing',
|
|
7184
|
+
positionMs,
|
|
7185
|
+
durationMs: trackDurationMs,
|
|
7186
|
+
request,
|
|
7187
|
+
});
|
|
7188
|
+
} catch (error) {
|
|
7189
|
+
request.log.warn(error, 'Failed to sync stream-start playback status to Plex');
|
|
7190
|
+
}
|
|
7191
|
+
|
|
7192
|
+
progressTimer = setInterval(async () => {
|
|
7193
|
+
if (progressSyncInFlight) {
|
|
7194
|
+
return;
|
|
7195
|
+
}
|
|
7196
|
+
|
|
7197
|
+
const current = playbackSessions.get(playbackClient.sessionKey);
|
|
7198
|
+
if (!current || current.itemId !== String(trackId) || current.state !== 'playing') {
|
|
7199
|
+
return;
|
|
7200
|
+
}
|
|
7201
|
+
|
|
7202
|
+
progressSyncInFlight = true;
|
|
7203
|
+
try {
|
|
7204
|
+
await syncClientPlaybackState({
|
|
7205
|
+
accountId: account.id,
|
|
7206
|
+
plexState,
|
|
7207
|
+
clientName,
|
|
7208
|
+
itemId: trackId,
|
|
7209
|
+
state: 'playing',
|
|
7210
|
+
positionMs: estimatePlaybackPositionMs(),
|
|
7211
|
+
durationMs: trackDurationMs,
|
|
7212
|
+
request,
|
|
7213
|
+
});
|
|
7214
|
+
} catch (error) {
|
|
7215
|
+
request.log.warn(error, 'Failed to sync stream progress playback status to Plex');
|
|
7216
|
+
} finally {
|
|
7217
|
+
progressSyncInFlight = false;
|
|
7218
|
+
}
|
|
7219
|
+
}, STREAM_PROGRESS_HEARTBEAT_MS);
|
|
7220
|
+
|
|
7221
|
+
if (typeof progressTimer.unref === 'function') {
|
|
7222
|
+
progressTimer.unref();
|
|
7223
|
+
}
|
|
7224
|
+
};
|
|
6886
7225
|
|
|
6887
|
-
let streamClosed = false;
|
|
6888
7226
|
const handleStreamClosed = () => {
|
|
6889
7227
|
if (streamClosed) {
|
|
6890
7228
|
return;
|
|
@@ -6893,6 +7231,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6893
7231
|
|
|
6894
7232
|
const closedAt = Date.now();
|
|
6895
7233
|
clearProgressTimer();
|
|
7234
|
+
clearSuppressedPromoteTimer();
|
|
6896
7235
|
const estimatedAtClose = estimatePlaybackPositionMs(closedAt);
|
|
6897
7236
|
let currentAtClose = playbackSessions.get(playbackClient.sessionKey);
|
|
6898
7237
|
if (currentAtClose && currentAtClose.itemId === String(trackId) && currentAtClose.state === 'playing') {
|
|
@@ -6974,51 +7313,71 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6974
7313
|
timer.unref();
|
|
6975
7314
|
}
|
|
6976
7315
|
};
|
|
7316
|
+
if (!streamPlaybackAuthorityDisabled) {
|
|
7317
|
+
reply.raw.once('close', handleStreamClosed);
|
|
7318
|
+
reply.raw.once('finish', handleStreamClosed);
|
|
7319
|
+
}
|
|
6977
7320
|
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
return;
|
|
6984
|
-
}
|
|
7321
|
+
if (suppressStreamLoadPlaybackSync) {
|
|
7322
|
+
request.log.debug(
|
|
7323
|
+
{ trackId, clientName, streamPlaybackAuthorityDisabled },
|
|
7324
|
+
'Suppressing immediate stream-start playback sync; awaiting continuity/persistence',
|
|
7325
|
+
);
|
|
6985
7326
|
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
7327
|
+
suppressedPromoteTimer = setTimeout(async () => {
|
|
7328
|
+
if (streamClosed || streamTrackingStarted) {
|
|
7329
|
+
return;
|
|
7330
|
+
}
|
|
6990
7331
|
|
|
6991
|
-
|
|
6992
|
-
try {
|
|
6993
|
-
await syncClientPlaybackState({
|
|
7332
|
+
const stillSuppressed = shouldSuppressPlaybackSyncForStreamLoad({
|
|
6994
7333
|
accountId: account.id,
|
|
6995
|
-
plexState,
|
|
6996
7334
|
clientName,
|
|
6997
|
-
|
|
6998
|
-
state: 'playing',
|
|
6999
|
-
positionMs: estimatePlaybackPositionMs(),
|
|
7000
|
-
durationMs: trackDurationMs,
|
|
7001
|
-
request,
|
|
7335
|
+
trackId,
|
|
7002
7336
|
});
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
}
|
|
7008
|
-
}, STREAM_PROGRESS_HEARTBEAT_MS);
|
|
7337
|
+
if (!stillSuppressed) {
|
|
7338
|
+
await startStreamPlaybackTracking(estimatePlaybackPositionMs());
|
|
7339
|
+
return;
|
|
7340
|
+
}
|
|
7009
7341
|
|
|
7010
|
-
|
|
7011
|
-
|
|
7342
|
+
const hasScrobbleDrivenContinuity = Number(continuityState.at || 0) > 0;
|
|
7343
|
+
if (hasScrobbleDrivenContinuity) {
|
|
7344
|
+
request.log.debug(
|
|
7345
|
+
{ trackId, clientName },
|
|
7346
|
+
'Keeping suppressed stream pending explicit scrobble/queue continuity',
|
|
7347
|
+
);
|
|
7348
|
+
return;
|
|
7349
|
+
}
|
|
7350
|
+
|
|
7351
|
+
// Fallback only for clients that never provide continuity signals.
|
|
7352
|
+
await startStreamPlaybackTracking(estimatePlaybackPositionMs());
|
|
7353
|
+
}, STREAM_SUPPRESSED_PROMOTE_DELAY_MS);
|
|
7354
|
+
|
|
7355
|
+
if (typeof suppressedPromoteTimer.unref === 'function') {
|
|
7356
|
+
suppressedPromoteTimer.unref();
|
|
7357
|
+
}
|
|
7358
|
+
} else {
|
|
7359
|
+
await startStreamPlaybackTracking(offsetMs);
|
|
7012
7360
|
}
|
|
7013
7361
|
|
|
7014
7362
|
const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
|
|
7015
7363
|
const rangeHeader = request.headers.range;
|
|
7364
|
+
const upstreamController = new AbortController();
|
|
7365
|
+
const abortUpstreamOnDisconnect = () => {
|
|
7366
|
+
if (!upstreamController.signal.aborted) {
|
|
7367
|
+
upstreamController.abort();
|
|
7368
|
+
}
|
|
7369
|
+
};
|
|
7370
|
+
request.raw.once('aborted', abortUpstreamOnDisconnect);
|
|
7371
|
+
request.raw.once('close', abortUpstreamOnDisconnect);
|
|
7372
|
+
reply.raw.once('close', abortUpstreamOnDisconnect);
|
|
7373
|
+
|
|
7016
7374
|
const upstream = await fetchWithRetry({
|
|
7017
7375
|
url: streamUrl,
|
|
7018
7376
|
options: {
|
|
7019
7377
|
headers: {
|
|
7020
7378
|
...(rangeHeader ? { Range: rangeHeader } : {}),
|
|
7021
7379
|
},
|
|
7380
|
+
signal: upstreamController.signal,
|
|
7022
7381
|
},
|
|
7023
7382
|
request,
|
|
7024
7383
|
context: 'track stream proxy',
|
|
@@ -7028,6 +7387,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
7028
7387
|
|
|
7029
7388
|
if (!upstream.ok || !upstream.body) {
|
|
7030
7389
|
clearProgressTimer();
|
|
7390
|
+
clearSuppressedPromoteTimer();
|
|
7031
7391
|
request.log.warn({ status: upstream.status }, 'Failed to proxy track stream');
|
|
7032
7392
|
return sendSubsonicError(reply, 70, 'Track stream unavailable');
|
|
7033
7393
|
}
|
|
@@ -7048,8 +7408,34 @@ export async function buildServer(config = loadConfig()) {
|
|
|
7048
7408
|
}
|
|
7049
7409
|
}
|
|
7050
7410
|
|
|
7051
|
-
|
|
7411
|
+
const proxiedBody = Readable.fromWeb(upstream.body);
|
|
7412
|
+
const responseBody = new PassThrough();
|
|
7413
|
+
|
|
7414
|
+
proxiedBody.on('error', (streamError) => {
|
|
7415
|
+
if (isAbortError(streamError) || isUpstreamTerminationError(streamError) || isClientDisconnected(request, reply)) {
|
|
7416
|
+
request.log.debug({ trackId, clientName }, 'Ignoring expected stream termination');
|
|
7417
|
+
responseBody.end();
|
|
7418
|
+
return;
|
|
7419
|
+
}
|
|
7420
|
+
request.log.warn(streamError, 'Upstream stream error while proxying track');
|
|
7421
|
+
responseBody.destroy(streamError);
|
|
7422
|
+
});
|
|
7423
|
+
|
|
7424
|
+
responseBody.on('error', (streamError) => {
|
|
7425
|
+
if (isAbortError(streamError) || isUpstreamTerminationError(streamError) || isClientDisconnected(request, reply)) {
|
|
7426
|
+
request.log.debug({ trackId, clientName }, 'Ignoring expected response stream termination');
|
|
7427
|
+
return;
|
|
7428
|
+
}
|
|
7429
|
+
request.log.warn(streamError, 'Response stream error while proxying track');
|
|
7430
|
+
});
|
|
7431
|
+
|
|
7432
|
+
proxiedBody.pipe(responseBody);
|
|
7433
|
+
return reply.send(responseBody);
|
|
7052
7434
|
} catch (error) {
|
|
7435
|
+
if (isAbortError(error) || isUpstreamTerminationError(error) || isClientDisconnected(request, reply)) {
|
|
7436
|
+
request.log.debug({ trackId }, 'Ignoring expected stream disconnect');
|
|
7437
|
+
return;
|
|
7438
|
+
}
|
|
7053
7439
|
request.log.error(error, 'Failed to proxy stream');
|
|
7054
7440
|
return sendSubsonicError(reply, 10, 'Stream proxy failed');
|
|
7055
7441
|
}
|