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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +460 -74
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plexsonic",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "PlexMusic to OpenSubsonic bridge",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
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
- if (now - Number(session.updatedAt || 0) > PLAYBACK_IDLE_TIMEOUT_MS) {
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
- ...getRequestParamValues(request, 'id'),
5454
- ...getRequestParamValues(request, 'songId'),
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 = ids[0] || '';
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
- (!shouldSubmit || ids.length === 1 || hasExplicitState) &&
5489
- (hasPlaybackProgress || hasExplicitState);
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 (shouldSubmit) {
5493
- await Promise.all(
5494
- ids.map((id) =>
5495
- scrobblePlexItem({
5496
- baseUrl: plexState.baseUrl,
5497
- plexToken: plexState.plexToken,
5498
- itemId: id,
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
- try {
5509
- if (shouldSyncPlayback) {
5510
- await syncClientPlaybackState({
5511
- accountId: account.id,
5512
- plexState,
5513
- clientName,
5514
- itemId: primaryTrackId,
5515
- state: playbackState,
5516
- positionMs: playbackPositionMs,
5517
- request,
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
- try {
6873
- await syncClientPlaybackState({
6874
- accountId: account.id,
6875
- plexState,
6876
- clientName,
6877
- itemId: trackId,
6878
- state: 'playing',
6879
- positionMs: offsetMs,
6880
- durationMs: trackDurationMs,
6881
- request,
6882
- });
6883
- } catch (error) {
6884
- request.log.warn(error, 'Failed to sync stream-start playback status to Plex');
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
- reply.raw.once('close', handleStreamClosed);
6979
- reply.raw.once('finish', handleStreamClosed);
6980
-
6981
- progressTimer = setInterval(async () => {
6982
- if (progressSyncInFlight) {
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
- const current = playbackSessions.get(playbackClient.sessionKey);
6987
- if (!current || current.itemId !== String(trackId) || current.state !== 'playing') {
6988
- return;
6989
- }
7327
+ suppressedPromoteTimer = setTimeout(async () => {
7328
+ if (streamClosed || streamTrackingStarted) {
7329
+ return;
7330
+ }
6990
7331
 
6991
- progressSyncInFlight = true;
6992
- try {
6993
- await syncClientPlaybackState({
7332
+ const stillSuppressed = shouldSuppressPlaybackSyncForStreamLoad({
6994
7333
  accountId: account.id,
6995
- plexState,
6996
7334
  clientName,
6997
- itemId: trackId,
6998
- state: 'playing',
6999
- positionMs: estimatePlaybackPositionMs(),
7000
- durationMs: trackDurationMs,
7001
- request,
7335
+ trackId,
7002
7336
  });
7003
- } catch (error) {
7004
- request.log.warn(error, 'Failed to sync stream progress playback status to Plex');
7005
- } finally {
7006
- progressSyncInFlight = false;
7007
- }
7008
- }, STREAM_PROGRESS_HEARTBEAT_MS);
7337
+ if (!stillSuppressed) {
7338
+ await startStreamPlaybackTracking(estimatePlaybackPositionMs());
7339
+ return;
7340
+ }
7009
7341
 
7010
- if (typeof progressTimer.unref === 'function') {
7011
- progressTimer.unref();
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
- return reply.send(Readable.fromWeb(upstream.body));
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
  }