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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +398 -73
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plexsonic",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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,
@@ -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
- ...getRequestParamValues(request, 'id'),
5454
- ...getRequestParamValues(request, 'songId'),
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 = ids[0] || '';
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
- (!shouldSubmit || ids.length === 1 || hasExplicitState) &&
5489
- (hasPlaybackProgress || hasExplicitState);
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 (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
- );
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
- 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
- });
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
- 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
- }
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
- reply.raw.once('close', handleStreamClosed);
6979
- reply.raw.once('finish', handleStreamClosed);
6980
-
6981
- progressTimer = setInterval(async () => {
6982
- if (progressSyncInFlight) {
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
- const current = playbackSessions.get(playbackClient.sessionKey);
6987
- if (!current || current.itemId !== String(trackId) || current.state !== 'playing') {
6988
- return;
6989
- }
7266
+ suppressedPromoteTimer = setTimeout(async () => {
7267
+ if (streamClosed || streamTrackingStarted) {
7268
+ return;
7269
+ }
6990
7270
 
6991
- progressSyncInFlight = true;
6992
- try {
6993
- await syncClientPlaybackState({
7271
+ const stillSuppressed = shouldSuppressPlaybackSyncForStreamLoad({
6994
7272
  accountId: account.id,
6995
- plexState,
6996
7273
  clientName,
6997
- itemId: trackId,
6998
- state: 'playing',
6999
- positionMs: estimatePlaybackPositionMs(),
7000
- durationMs: trackDurationMs,
7001
- request,
7274
+ trackId,
7002
7275
  });
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);
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
- if (typeof progressTimer.unref === 'function') {
7011
- progressTimer.unref();
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
- return reply.send(Readable.fromWeb(upstream.body));
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
  }