plexsonic 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plexsonic",
3
- "version": "0.1.5",
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';
@@ -868,6 +868,111 @@ function isPlexLiked(value) {
868
868
  return normalized != null && normalized >= 2 && normalized % 2 === 0;
869
869
  }
870
870
 
871
+ function normalizePlainText(value) {
872
+ return String(value || '')
873
+ .replace(/<br\s*\/?>/gi, '\n')
874
+ .replace(/<\/p>/gi, '\n')
875
+ .replace(/<[^>]*>/g, '')
876
+ .replace(/\r\n/g, '\n')
877
+ .replace(/\n{3,}/g, '\n\n')
878
+ .trim();
879
+ }
880
+
881
+ function asArray(value) {
882
+ if (Array.isArray(value)) {
883
+ return value;
884
+ }
885
+ if (value == null) {
886
+ return [];
887
+ }
888
+ return [value];
889
+ }
890
+
891
+ function plexGuidIds(item) {
892
+ const candidates = [];
893
+
894
+ for (const guid of asArray(item?.Guid)) {
895
+ if (typeof guid === 'string') {
896
+ candidates.push(guid);
897
+ continue;
898
+ }
899
+ if (guid && typeof guid === 'object' && typeof guid.id === 'string') {
900
+ candidates.push(guid.id);
901
+ }
902
+ }
903
+
904
+ for (const raw of [item?.guid, item?.guids]) {
905
+ if (typeof raw === 'string') {
906
+ candidates.push(raw);
907
+ } else if (Array.isArray(raw)) {
908
+ for (const entry of raw) {
909
+ if (typeof entry === 'string') {
910
+ candidates.push(entry);
911
+ } else if (entry && typeof entry === 'object' && typeof entry.id === 'string') {
912
+ candidates.push(entry.id);
913
+ }
914
+ }
915
+ }
916
+ }
917
+
918
+ return uniqueNonEmptyValues(candidates);
919
+ }
920
+
921
+ function extractMusicBrainzArtistId(item) {
922
+ const guidIds = plexGuidIds(item);
923
+ const uuidPattern = /([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})/i;
924
+
925
+ for (const guid of guidIds) {
926
+ const lower = safeLower(guid);
927
+
928
+ if (lower.startsWith('mbid://')) {
929
+ const id = guid.slice('mbid://'.length).split(/[/?#]/, 1)[0].trim();
930
+ if (id) {
931
+ return id;
932
+ }
933
+ }
934
+
935
+ if (lower.startsWith('musicbrainz://')) {
936
+ const id = guid
937
+ .slice('musicbrainz://'.length)
938
+ .replace(/^artist\//i, '')
939
+ .split(/[?#]/, 1)[0]
940
+ .replace(/^\/+/, '')
941
+ .trim();
942
+ if (id) {
943
+ return id;
944
+ }
945
+ }
946
+
947
+ if (lower.includes('musicbrainz.org/artist/')) {
948
+ const match = guid.match(/musicbrainz\.org\/artist\/([^/?#]+)/i);
949
+ if (match?.[1]) {
950
+ return match[1];
951
+ }
952
+ }
953
+
954
+ if (lower.includes('musicbrainz')) {
955
+ const uuid = guid.match(uuidPattern)?.[1];
956
+ if (uuid) {
957
+ return uuid;
958
+ }
959
+ }
960
+ }
961
+
962
+ return '';
963
+ }
964
+
965
+ function artistBioFromPlex(item) {
966
+ return firstNonEmptyText(
967
+ [
968
+ normalizePlainText(item?.summary),
969
+ normalizePlainText(item?.tagline),
970
+ normalizePlainText(item?.description),
971
+ ],
972
+ '',
973
+ );
974
+ }
975
+
871
976
  function subsonicRatingToPlexRating(value, { liked = false } = {}) {
872
977
  const rating = Number.parseInt(String(value ?? ''), 10);
873
978
  if (!Number.isFinite(rating) || rating <= 0) {
@@ -1694,6 +1799,35 @@ function isAbortError(error) {
1694
1799
  return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
1695
1800
  }
1696
1801
 
1802
+ function isUpstreamTerminationError(error) {
1803
+ if (!error) {
1804
+ return false;
1805
+ }
1806
+
1807
+ const message = String(error?.message || '').toLowerCase();
1808
+ const causeMessage = String(error?.cause?.message || '').toLowerCase();
1809
+ const code = String(error?.code || error?.cause?.code || '').toUpperCase();
1810
+ const causeName = String(error?.cause?.name || '').toLowerCase();
1811
+
1812
+ return (
1813
+ code === 'ECONNRESET' ||
1814
+ code === 'EPIPE' ||
1815
+ message.includes('terminated') ||
1816
+ message.includes('other side closed') ||
1817
+ causeMessage.includes('other side closed') ||
1818
+ causeName.includes('socketerror')
1819
+ );
1820
+ }
1821
+
1822
+ function isClientDisconnected(request, reply) {
1823
+ return Boolean(
1824
+ request?.raw?.aborted ||
1825
+ request?.raw?.destroyed ||
1826
+ reply?.raw?.destroyed ||
1827
+ reply?.raw?.writableEnded,
1828
+ );
1829
+ }
1830
+
1697
1831
  const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
1698
1832
 
1699
1833
  function waitMs(ms) {
@@ -2346,12 +2480,16 @@ export async function buildServer(config = loadConfig()) {
2346
2480
  });
2347
2481
 
2348
2482
  const playbackSessions = new Map();
2483
+ const recentScrobblesByClient = new Map();
2349
2484
  const savedPlayQueues = new Map();
2350
2485
  const PLAYBACK_RECONCILE_INTERVAL_MS = 15000;
2351
2486
  const PLAYBACK_IDLE_TIMEOUT_MS = 120000;
2352
2487
  const STREAM_DISCONNECT_STOP_DELAY_MS = 4000;
2353
2488
  const PLAYBACK_MAX_DISCONNECT_WAIT_MS = 30 * 60 * 1000;
2354
2489
  const STREAM_PROGRESS_HEARTBEAT_MS = 10000;
2490
+ const STREAM_PRELOAD_SUPPRESS_AFTER_SCROBBLE_MS = 1500;
2491
+ const STREAM_SUPPRESSED_PROMOTE_DELAY_MS = 1200;
2492
+ const PLAYBACK_CONTINUITY_AFTER_SCROBBLE_MS = 15000;
2355
2493
  const PLAY_QUEUE_IDLE_TTL_MS = 6 * 60 * 60 * 1000;
2356
2494
  const activeSearchRequests = new Map();
2357
2495
  const searchBrowseCache = new Map();
@@ -3161,6 +3299,116 @@ export async function buildServer(config = loadConfig()) {
3161
3299
  };
3162
3300
  }
3163
3301
 
3302
+ function pruneRecentScrobbles(now = Date.now()) {
3303
+ void now;
3304
+ for (const [key, value] of recentScrobblesByClient.entries()) {
3305
+ if (!value) {
3306
+ recentScrobblesByClient.delete(key);
3307
+ }
3308
+ }
3309
+ }
3310
+
3311
+ function getPlaybackContinuityState(accountId, clientName) {
3312
+ const playbackClient = playbackClientContext(accountId, clientName);
3313
+ let state = recentScrobblesByClient.get(playbackClient.sessionKey);
3314
+ if (!state) {
3315
+ state = {
3316
+ at: 0,
3317
+ trackId: '',
3318
+ queueCurrentId: '',
3319
+ queueIds: [],
3320
+ };
3321
+ recentScrobblesByClient.set(playbackClient.sessionKey, state);
3322
+ }
3323
+ return { playbackClient, state };
3324
+ }
3325
+
3326
+ function resolveQueuedNextTrackId(state, currentTrackId) {
3327
+ const normalizedCurrent = String(currentTrackId || '').trim();
3328
+ if (!normalizedCurrent) {
3329
+ return '';
3330
+ }
3331
+
3332
+ const queueIds = Array.isArray(state?.queueIds) ? state.queueIds : [];
3333
+ if (queueIds.length === 0) {
3334
+ return '';
3335
+ }
3336
+
3337
+ const index = queueIds.findIndex((id) => String(id || '').trim() === normalizedCurrent);
3338
+ if (index === -1) {
3339
+ return '';
3340
+ }
3341
+ const next = String(queueIds[index + 1] || '').trim();
3342
+ return next;
3343
+ }
3344
+
3345
+ function notePlaybackQueueContext({ accountId, clientName, currentTrackId = '', queueIds = [] }) {
3346
+ const { state } = getPlaybackContinuityState(accountId, clientName);
3347
+ state.queueCurrentId = String(currentTrackId || '').trim();
3348
+ state.queueIds = uniqueNonEmptyValues(Array.isArray(queueIds) ? queueIds : []);
3349
+ }
3350
+
3351
+ function noteRecentScrobble({ accountId, clientName, trackId = '' }) {
3352
+ const now = Date.now();
3353
+ const { state } = getPlaybackContinuityState(accountId, clientName);
3354
+ state.at = now;
3355
+ state.trackId = String(trackId || '').trim();
3356
+ pruneRecentScrobbles(now);
3357
+ }
3358
+
3359
+ function shouldSuppressPlaybackSyncForStreamLoad({ accountId, clientName, trackId }) {
3360
+ const now = Date.now();
3361
+ pruneRecentScrobbles(now);
3362
+
3363
+ const { playbackClient, state: recent } = getPlaybackContinuityState(accountId, clientName);
3364
+ const current = playbackSessions.get(playbackClient.sessionKey);
3365
+
3366
+ const normalizedTrackId = String(trackId || '').trim();
3367
+ if (!normalizedTrackId) {
3368
+ return false;
3369
+ }
3370
+
3371
+ const currentTrackId = String(current?.itemId || '').trim();
3372
+ if (current?.state === 'playing' && currentTrackId && normalizedTrackId === currentTrackId) {
3373
+ return false;
3374
+ }
3375
+
3376
+ const queuedCurrentId = String(recent.queueCurrentId || '').trim();
3377
+ if (queuedCurrentId && queuedCurrentId === normalizedTrackId) {
3378
+ return false;
3379
+ }
3380
+
3381
+ const recentTrackId = String(recent.trackId || '').trim();
3382
+ if (
3383
+ recentTrackId &&
3384
+ recentTrackId === currentTrackId &&
3385
+ (now - Number(recent.at || 0)) <= PLAYBACK_CONTINUITY_AFTER_SCROBBLE_MS
3386
+ ) {
3387
+ const queuedNextTrackId = resolveQueuedNextTrackId(recent, recentTrackId);
3388
+ if (queuedNextTrackId && queuedNextTrackId === normalizedTrackId) {
3389
+ return false;
3390
+ }
3391
+ }
3392
+
3393
+ // No confirmed playback and no trusted continuity signal: treat stream load as preload.
3394
+ if (!current || current.state !== 'playing') {
3395
+ return true;
3396
+ }
3397
+
3398
+ // Current track exists but stale: still require explicit continuity signal to avoid preload flashes.
3399
+ if ((now - Number(current.updatedAt || 0)) > PLAYBACK_IDLE_TIMEOUT_MS) {
3400
+ return true;
3401
+ }
3402
+
3403
+ // Suppress short-lived preloads right after a scrobble signal.
3404
+ if ((now - Number(recent.at || 0)) <= STREAM_PRELOAD_SUPPRESS_AFTER_SCROBBLE_MS) {
3405
+ return true;
3406
+ }
3407
+
3408
+ // Continuity mode: keep current track authoritative unless trusted signal allows switch.
3409
+ return true;
3410
+ }
3411
+
3164
3412
  async function syncClientPlaybackState({
3165
3413
  accountId,
3166
3414
  plexState,
@@ -5331,6 +5579,13 @@ export async function buildServer(config = loadConfig()) {
5331
5579
  updatedAt: now,
5332
5580
  });
5333
5581
  pruneSavedPlayQueues(now);
5582
+ const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
5583
+ notePlaybackQueueContext({
5584
+ accountId: account.id,
5585
+ clientName,
5586
+ currentTrackId: current,
5587
+ queueIds: ids,
5588
+ });
5334
5589
 
5335
5590
  return sendSubsonicOk(reply);
5336
5591
  });
@@ -5344,9 +5599,11 @@ export async function buildServer(config = loadConfig()) {
5344
5599
  return;
5345
5600
  }
5346
5601
 
5602
+ const songIds = uniqueNonEmptyValues(getRequestParamValues(request, 'songId'));
5603
+ const genericIds = uniqueNonEmptyValues(getRequestParamValues(request, 'id'));
5347
5604
  const ids = uniqueNonEmptyValues([
5348
- ...getRequestParamValues(request, 'id'),
5349
- ...getRequestParamValues(request, 'songId'),
5605
+ ...songIds,
5606
+ ...genericIds,
5350
5607
  ]);
5351
5608
 
5352
5609
  if (ids.length === 0) {
@@ -5377,43 +5634,113 @@ export async function buildServer(config = loadConfig()) {
5377
5634
  ? Math.round(parsedOffset * 1000)
5378
5635
  : 0;
5379
5636
  const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
5380
- const primaryTrackId = 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;
5381
5650
  const shouldSyncPlayback =
5382
5651
  Boolean(primaryTrackId) &&
5383
- (!shouldSubmit || ids.length === 1 || hasExplicitState) &&
5384
- (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
+ }
5385
5673
 
5386
5674
  try {
5387
- if (shouldSubmit) {
5388
- await Promise.all(
5389
- ids.map((id) =>
5390
- scrobblePlexItem({
5391
- baseUrl: plexState.baseUrl,
5392
- plexToken: plexState.plexToken,
5393
- itemId: id,
5394
- }),
5395
- ),
5396
- );
5675
+ if (submissionIds.length > 0) {
5676
+ const [firstId, ...restIds] = submissionIds;
5677
+ await scrobblePlexItem({
5678
+ baseUrl: plexState.baseUrl,
5679
+ plexToken: plexState.plexToken,
5680
+ itemId: firstId,
5681
+ });
5682
+ if (restIds.length > 0) {
5683
+ await Promise.all(
5684
+ restIds.map((id) =>
5685
+ scrobblePlexItem({
5686
+ baseUrl: plexState.baseUrl,
5687
+ plexToken: plexState.plexToken,
5688
+ itemId: id,
5689
+ }),
5690
+ ),
5691
+ );
5692
+ }
5397
5693
  }
5398
5694
  } catch (error) {
5399
5695
  request.log.error(error, 'Failed to sync scrobble to Plex');
5400
5696
  return sendSubsonicError(reply, 10, 'Failed to scrobble');
5401
5697
  }
5402
5698
 
5403
- try {
5404
- if (shouldSyncPlayback) {
5405
- await syncClientPlaybackState({
5406
- accountId: account.id,
5407
- plexState,
5408
- clientName,
5409
- itemId: primaryTrackId,
5410
- state: playbackState,
5411
- positionMs: playbackPositionMs,
5412
- request,
5413
- });
5699
+ if (playbackSyncPromise) {
5700
+ await playbackSyncPromise;
5701
+ }
5702
+
5703
+ noteRecentScrobble({
5704
+ accountId: account.id,
5705
+ clientName,
5706
+ trackId: primaryTrackId,
5707
+ });
5708
+
5709
+ const allowQueuedPromotion =
5710
+ shouldSubmit &&
5711
+ Boolean(primaryTrackId) &&
5712
+ (
5713
+ (hasExplicitState && playbackState === 'stopped') ||
5714
+ (hasPlaybackProgress && playbackPositionMs > 0)
5715
+ );
5716
+
5717
+ if (allowQueuedPromotion) {
5718
+ const continuity = recentScrobblesByClient.get(playbackClient.sessionKey);
5719
+ const queuedNextTrackId = resolveQueuedNextTrackId(continuity, primaryTrackId);
5720
+ if (queuedNextTrackId) {
5721
+ const current = playbackSessions.get(playbackClient.sessionKey);
5722
+ const currentTrackId = String(current?.itemId || '').trim();
5723
+ const shouldPromoteQueuedNext =
5724
+ !current ||
5725
+ current.state !== 'playing' ||
5726
+ currentTrackId === primaryTrackId;
5727
+
5728
+ if (shouldPromoteQueuedNext) {
5729
+ try {
5730
+ await syncClientPlaybackState({
5731
+ accountId: account.id,
5732
+ plexState,
5733
+ clientName,
5734
+ itemId: queuedNextTrackId,
5735
+ state: 'playing',
5736
+ positionMs: 0,
5737
+ request,
5738
+ });
5739
+ } catch (error) {
5740
+ request.log.warn(error, 'Failed to promote queued next track after scrobble');
5741
+ }
5742
+ }
5414
5743
  }
5415
- } catch (error) {
5416
- request.log.warn(error, 'Failed to sync playback status to Plex');
5417
5744
  }
5418
5745
 
5419
5746
  return sendSubsonicOk(reply);
@@ -6131,7 +6458,61 @@ export async function buildServer(config = loadConfig()) {
6131
6458
  return;
6132
6459
  }
6133
6460
 
6134
- return sendSubsonicOk(reply, node('artistInfo'));
6461
+ const artistId = String(getRequestParam(request, 'id') || '').trim();
6462
+ if (!artistId) {
6463
+ return sendSubsonicError(reply, 70, 'Missing artist id');
6464
+ }
6465
+
6466
+ const context = repo.getAccountPlexContext(account.id);
6467
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
6468
+ if (!plexState) {
6469
+ return;
6470
+ }
6471
+
6472
+ try {
6473
+ let artist = null;
6474
+ try {
6475
+ artist = await getArtist({
6476
+ baseUrl: plexState.baseUrl,
6477
+ plexToken: plexState.plexToken,
6478
+ artistId,
6479
+ });
6480
+ } catch (error) {
6481
+ if (!isPlexNotFoundError(error)) {
6482
+ throw error;
6483
+ }
6484
+ }
6485
+
6486
+ if (!artist) {
6487
+ const fallback = await resolveArtistFromCachedLibrary({
6488
+ accountId: account.id,
6489
+ plexState,
6490
+ request,
6491
+ artistId,
6492
+ });
6493
+ if (fallback?.artist) {
6494
+ artist = fallback.artist;
6495
+ }
6496
+ }
6497
+
6498
+ if (!artist) {
6499
+ return sendSubsonicError(reply, 70, 'Artist not found');
6500
+ }
6501
+
6502
+ const biography = artistBioFromPlex(artist);
6503
+ const musicBrainzId = extractMusicBrainzArtistId(artist);
6504
+ const children = [
6505
+ biography ? node('biography', {}, biography) : '',
6506
+ musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : '',
6507
+ ]
6508
+ .filter(Boolean)
6509
+ .join('');
6510
+
6511
+ return sendSubsonicOk(reply, node('artistInfo', {}, children));
6512
+ } catch (error) {
6513
+ request.log.error(error, 'Failed to load artist info');
6514
+ return sendSubsonicError(reply, 10, 'Failed to load artist info');
6515
+ }
6135
6516
  });
6136
6517
 
6137
6518
  app.get('/rest/getArtistInfo2.view', async (request, reply) => {
@@ -6140,7 +6521,61 @@ export async function buildServer(config = loadConfig()) {
6140
6521
  return;
6141
6522
  }
6142
6523
 
6143
- return sendSubsonicOk(reply, node('artistInfo2'));
6524
+ const artistId = String(getRequestParam(request, 'id') || '').trim();
6525
+ if (!artistId) {
6526
+ return sendSubsonicError(reply, 70, 'Missing artist id');
6527
+ }
6528
+
6529
+ const context = repo.getAccountPlexContext(account.id);
6530
+ const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
6531
+ if (!plexState) {
6532
+ return;
6533
+ }
6534
+
6535
+ try {
6536
+ let artist = null;
6537
+ try {
6538
+ artist = await getArtist({
6539
+ baseUrl: plexState.baseUrl,
6540
+ plexToken: plexState.plexToken,
6541
+ artistId,
6542
+ });
6543
+ } catch (error) {
6544
+ if (!isPlexNotFoundError(error)) {
6545
+ throw error;
6546
+ }
6547
+ }
6548
+
6549
+ if (!artist) {
6550
+ const fallback = await resolveArtistFromCachedLibrary({
6551
+ accountId: account.id,
6552
+ plexState,
6553
+ request,
6554
+ artistId,
6555
+ });
6556
+ if (fallback?.artist) {
6557
+ artist = fallback.artist;
6558
+ }
6559
+ }
6560
+
6561
+ if (!artist) {
6562
+ return sendSubsonicError(reply, 70, 'Artist not found');
6563
+ }
6564
+
6565
+ const biography = artistBioFromPlex(artist);
6566
+ const musicBrainzId = extractMusicBrainzArtistId(artist);
6567
+ const children = [
6568
+ biography ? node('biography', {}, biography) : '',
6569
+ musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : '',
6570
+ ]
6571
+ .filter(Boolean)
6572
+ .join('');
6573
+
6574
+ return sendSubsonicOk(reply, node('artistInfo2', {}, children));
6575
+ } catch (error) {
6576
+ request.log.error(error, 'Failed to load artist info2');
6577
+ return sendSubsonicError(reply, 10, 'Failed to load artist info');
6578
+ }
6144
6579
  });
6145
6580
 
6146
6581
  app.get('/rest/getAlbum.view', async (request, reply) => {
@@ -6632,6 +7067,13 @@ export async function buildServer(config = loadConfig()) {
6632
7067
 
6633
7068
  const clientName = getRequestParam(request, 'c') || 'Subsonic Client';
6634
7069
  const playbackClient = playbackClientContext(account.id, clientName);
7070
+ const { state: continuityState } = getPlaybackContinuityState(account.id, clientName);
7071
+ const streamPlaybackAuthorityDisabled = Number(continuityState.at || 0) > 0;
7072
+ const suppressStreamLoadPlaybackSync = shouldSuppressPlaybackSyncForStreamLoad({
7073
+ accountId: account.id,
7074
+ clientName,
7075
+ trackId,
7076
+ }) || streamPlaybackAuthorityDisabled;
6635
7077
  const offsetRaw = getRequestParam(request, 'timeOffset');
6636
7078
  const offsetSeconds = Number.parseFloat(offsetRaw);
6637
7079
  const offsetMs = Number.isFinite(offsetSeconds) && offsetSeconds > 0 ? Math.round(offsetSeconds * 1000) : 0;
@@ -6655,23 +7097,71 @@ export async function buildServer(config = loadConfig()) {
6655
7097
  progressTimer = null;
6656
7098
  }
6657
7099
  };
7100
+ let streamClosed = false;
7101
+ let streamTrackingStarted = false;
7102
+ let suppressedPromoteTimer = null;
7103
+ const clearSuppressedPromoteTimer = () => {
7104
+ if (suppressedPromoteTimer) {
7105
+ clearTimeout(suppressedPromoteTimer);
7106
+ suppressedPromoteTimer = null;
7107
+ }
7108
+ };
6658
7109
 
6659
- try {
6660
- await syncClientPlaybackState({
6661
- accountId: account.id,
6662
- plexState,
6663
- clientName,
6664
- itemId: trackId,
6665
- state: 'playing',
6666
- positionMs: offsetMs,
6667
- durationMs: trackDurationMs,
6668
- request,
6669
- });
6670
- } catch (error) {
6671
- request.log.warn(error, 'Failed to sync stream-start playback status to Plex');
6672
- }
7110
+ const startStreamPlaybackTracking = async (positionMs) => {
7111
+ if (streamTrackingStarted || streamClosed) {
7112
+ return;
7113
+ }
7114
+ streamTrackingStarted = true;
7115
+
7116
+ try {
7117
+ await syncClientPlaybackState({
7118
+ accountId: account.id,
7119
+ plexState,
7120
+ clientName,
7121
+ itemId: trackId,
7122
+ state: 'playing',
7123
+ positionMs,
7124
+ durationMs: trackDurationMs,
7125
+ request,
7126
+ });
7127
+ } catch (error) {
7128
+ request.log.warn(error, 'Failed to sync stream-start playback status to Plex');
7129
+ }
7130
+
7131
+ progressTimer = setInterval(async () => {
7132
+ if (progressSyncInFlight) {
7133
+ return;
7134
+ }
7135
+
7136
+ const current = playbackSessions.get(playbackClient.sessionKey);
7137
+ if (!current || current.itemId !== String(trackId) || current.state !== 'playing') {
7138
+ return;
7139
+ }
7140
+
7141
+ progressSyncInFlight = true;
7142
+ try {
7143
+ await syncClientPlaybackState({
7144
+ accountId: account.id,
7145
+ plexState,
7146
+ clientName,
7147
+ itemId: trackId,
7148
+ state: 'playing',
7149
+ positionMs: estimatePlaybackPositionMs(),
7150
+ durationMs: trackDurationMs,
7151
+ request,
7152
+ });
7153
+ } catch (error) {
7154
+ request.log.warn(error, 'Failed to sync stream progress playback status to Plex');
7155
+ } finally {
7156
+ progressSyncInFlight = false;
7157
+ }
7158
+ }, STREAM_PROGRESS_HEARTBEAT_MS);
7159
+
7160
+ if (typeof progressTimer.unref === 'function') {
7161
+ progressTimer.unref();
7162
+ }
7163
+ };
6673
7164
 
6674
- let streamClosed = false;
6675
7165
  const handleStreamClosed = () => {
6676
7166
  if (streamClosed) {
6677
7167
  return;
@@ -6680,6 +7170,7 @@ export async function buildServer(config = loadConfig()) {
6680
7170
 
6681
7171
  const closedAt = Date.now();
6682
7172
  clearProgressTimer();
7173
+ clearSuppressedPromoteTimer();
6683
7174
  const estimatedAtClose = estimatePlaybackPositionMs(closedAt);
6684
7175
  let currentAtClose = playbackSessions.get(playbackClient.sessionKey);
6685
7176
  if (currentAtClose && currentAtClose.itemId === String(trackId) && currentAtClose.state === 'playing') {
@@ -6761,51 +7252,71 @@ export async function buildServer(config = loadConfig()) {
6761
7252
  timer.unref();
6762
7253
  }
6763
7254
  };
7255
+ if (!streamPlaybackAuthorityDisabled) {
7256
+ reply.raw.once('close', handleStreamClosed);
7257
+ reply.raw.once('finish', handleStreamClosed);
7258
+ }
6764
7259
 
6765
- reply.raw.once('close', handleStreamClosed);
6766
- reply.raw.once('finish', handleStreamClosed);
6767
-
6768
- progressTimer = setInterval(async () => {
6769
- if (progressSyncInFlight) {
6770
- return;
6771
- }
7260
+ if (suppressStreamLoadPlaybackSync) {
7261
+ request.log.debug(
7262
+ { trackId, clientName, streamPlaybackAuthorityDisabled },
7263
+ 'Suppressing immediate stream-start playback sync; awaiting continuity/persistence',
7264
+ );
6772
7265
 
6773
- const current = playbackSessions.get(playbackClient.sessionKey);
6774
- if (!current || current.itemId !== String(trackId) || current.state !== 'playing') {
6775
- return;
6776
- }
7266
+ suppressedPromoteTimer = setTimeout(async () => {
7267
+ if (streamClosed || streamTrackingStarted) {
7268
+ return;
7269
+ }
6777
7270
 
6778
- progressSyncInFlight = true;
6779
- try {
6780
- await syncClientPlaybackState({
7271
+ const stillSuppressed = shouldSuppressPlaybackSyncForStreamLoad({
6781
7272
  accountId: account.id,
6782
- plexState,
6783
7273
  clientName,
6784
- itemId: trackId,
6785
- state: 'playing',
6786
- positionMs: estimatePlaybackPositionMs(),
6787
- durationMs: trackDurationMs,
6788
- request,
7274
+ trackId,
6789
7275
  });
6790
- } catch (error) {
6791
- request.log.warn(error, 'Failed to sync stream progress playback status to Plex');
6792
- } finally {
6793
- progressSyncInFlight = false;
6794
- }
6795
- }, STREAM_PROGRESS_HEARTBEAT_MS);
7276
+ if (!stillSuppressed) {
7277
+ await startStreamPlaybackTracking(estimatePlaybackPositionMs());
7278
+ return;
7279
+ }
6796
7280
 
6797
- if (typeof progressTimer.unref === 'function') {
6798
- progressTimer.unref();
7281
+ const hasScrobbleDrivenContinuity = Number(continuityState.at || 0) > 0;
7282
+ if (hasScrobbleDrivenContinuity) {
7283
+ request.log.debug(
7284
+ { trackId, clientName },
7285
+ 'Keeping suppressed stream pending explicit scrobble/queue continuity',
7286
+ );
7287
+ return;
7288
+ }
7289
+
7290
+ // Fallback only for clients that never provide continuity signals.
7291
+ await startStreamPlaybackTracking(estimatePlaybackPositionMs());
7292
+ }, STREAM_SUPPRESSED_PROMOTE_DELAY_MS);
7293
+
7294
+ if (typeof suppressedPromoteTimer.unref === 'function') {
7295
+ suppressedPromoteTimer.unref();
7296
+ }
7297
+ } else {
7298
+ await startStreamPlaybackTracking(offsetMs);
6799
7299
  }
6800
7300
 
6801
7301
  const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
6802
7302
  const rangeHeader = request.headers.range;
7303
+ const upstreamController = new AbortController();
7304
+ const abortUpstreamOnDisconnect = () => {
7305
+ if (!upstreamController.signal.aborted) {
7306
+ upstreamController.abort();
7307
+ }
7308
+ };
7309
+ request.raw.once('aborted', abortUpstreamOnDisconnect);
7310
+ request.raw.once('close', abortUpstreamOnDisconnect);
7311
+ reply.raw.once('close', abortUpstreamOnDisconnect);
7312
+
6803
7313
  const upstream = await fetchWithRetry({
6804
7314
  url: streamUrl,
6805
7315
  options: {
6806
7316
  headers: {
6807
7317
  ...(rangeHeader ? { Range: rangeHeader } : {}),
6808
7318
  },
7319
+ signal: upstreamController.signal,
6809
7320
  },
6810
7321
  request,
6811
7322
  context: 'track stream proxy',
@@ -6815,6 +7326,7 @@ export async function buildServer(config = loadConfig()) {
6815
7326
 
6816
7327
  if (!upstream.ok || !upstream.body) {
6817
7328
  clearProgressTimer();
7329
+ clearSuppressedPromoteTimer();
6818
7330
  request.log.warn({ status: upstream.status }, 'Failed to proxy track stream');
6819
7331
  return sendSubsonicError(reply, 70, 'Track stream unavailable');
6820
7332
  }
@@ -6835,8 +7347,34 @@ export async function buildServer(config = loadConfig()) {
6835
7347
  }
6836
7348
  }
6837
7349
 
6838
- 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);
6839
7373
  } catch (error) {
7374
+ if (isAbortError(error) || isUpstreamTerminationError(error) || isClientDisconnected(request, reply)) {
7375
+ request.log.debug({ trackId }, 'Ignoring expected stream disconnect');
7376
+ return;
7377
+ }
6840
7378
  request.log.error(error, 'Failed to proxy stream');
6841
7379
  return sendSubsonicError(reply, 10, 'Stream proxy failed');
6842
7380
  }
@@ -321,6 +321,11 @@ function nodeToJson(node) {
321
321
  }
322
322
  }
323
323
 
324
+ const keys = Object.keys(out);
325
+ if (keys.length === 1 && keys[0] === 'value') {
326
+ return out.value;
327
+ }
328
+
324
329
  if (node.name === 'openSubsonicExtensions') {
325
330
  const extensions = out.openSubsonicExtension;
326
331
  if (Array.isArray(extensions)) {