plexsonic 0.1.0

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/src/plex.js ADDED
@@ -0,0 +1,1335 @@
1
+ /* Copyright Yukino Song, SudoMaker Ltd.
2
+ *
3
+ * Licensed to the Apache Software Foundation (ASF) under one
4
+ * or more contributor license agreements. See the NOTICE file
5
+ * distributed with this work for additional information
6
+ * regarding copyright ownership. The ASF licenses this file
7
+ * to you under the Apache License, Version 2.0 (the
8
+ * "License"); you may not use this file except in compliance
9
+ * with the License. You may obtain a copy of the License at
10
+ *
11
+ * http://www.apache.org/licenses/LICENSE-2.0
12
+ *
13
+ * Unless required by applicable law or agreed to in writing,
14
+ * software distributed under the License is distributed on an
15
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16
+ * KIND, either express or implied. See the License for the
17
+ * specific language governing permissions and limitations
18
+ * under the License.
19
+ */
20
+
21
+ const PLEX_TV_BASE = 'https://plex.tv';
22
+ const PLEX_CLIENTS_BASE = 'https://clients.plex.tv';
23
+
24
+ function makePlexHeaders(config, token = null) {
25
+ const headers = {
26
+ Accept: 'application/json',
27
+ 'X-Plex-Product': config.plexProduct,
28
+ 'X-Plex-Version': '0.1.0',
29
+ 'X-Plex-Client-Identifier': config.plexClientIdentifier,
30
+ 'X-Plex-Platform': process.platform,
31
+ 'X-Plex-Device': 'Plexsonic Bridge',
32
+ };
33
+
34
+ if (token) {
35
+ headers['X-Plex-Token'] = token;
36
+ }
37
+
38
+ return headers;
39
+ }
40
+
41
+ async function ensureJson(response, context) {
42
+ const body = await response.text();
43
+
44
+ if (!response.ok) {
45
+ throw new Error(`${context} failed (${response.status}): ${body.slice(0, 400)}`);
46
+ }
47
+
48
+ try {
49
+ return JSON.parse(body);
50
+ } catch (error) {
51
+ throw new Error(`${context} returned non-JSON response: ${error.message}`);
52
+ }
53
+ }
54
+
55
+ function asArray(value) {
56
+ if (Array.isArray(value)) {
57
+ return value;
58
+ }
59
+ if (value == null) {
60
+ return [];
61
+ }
62
+ return [value];
63
+ }
64
+
65
+ function toBool(value) {
66
+ if (typeof value === 'boolean') {
67
+ return value;
68
+ }
69
+ if (typeof value === 'number') {
70
+ return value !== 0;
71
+ }
72
+ if (typeof value === 'string') {
73
+ return value === '1' || value.toLowerCase() === 'true';
74
+ }
75
+ return false;
76
+ }
77
+
78
+ function extractResources(payload) {
79
+ if (Array.isArray(payload)) {
80
+ return payload;
81
+ }
82
+
83
+ const container = payload?.MediaContainer ?? payload ?? {};
84
+ if (Array.isArray(container)) {
85
+ return container;
86
+ }
87
+
88
+ if (Array.isArray(container.Device)) {
89
+ return container.Device;
90
+ }
91
+ if (container.Device) {
92
+ return [container.Device];
93
+ }
94
+
95
+ if (Array.isArray(container.Metadata)) {
96
+ return container.Metadata;
97
+ }
98
+ if (container.Metadata) {
99
+ return [container.Metadata];
100
+ }
101
+
102
+ if (Array.isArray(container.resources)) {
103
+ return container.resources;
104
+ }
105
+ if (container.resources) {
106
+ return [container.resources];
107
+ }
108
+
109
+ return [];
110
+ }
111
+
112
+ function scoreConnection(entry) {
113
+ const local = entry.local && !entry.relay;
114
+ const isHttp = entry.protocol === 'http' || entry.uri.startsWith('http://');
115
+
116
+ if (local && isHttp && entry.uri.includes(entry.address)) {
117
+ return 0;
118
+ }
119
+ if (local && isHttp) {
120
+ return 1;
121
+ }
122
+ if (local) {
123
+ return 2;
124
+ }
125
+ if (isHttp) {
126
+ return 3;
127
+ }
128
+ if (!entry.relay) {
129
+ return 4;
130
+ }
131
+ return 5;
132
+ }
133
+
134
+ function chooseConnectionCandidates(connections) {
135
+ const normalized = connections
136
+ .map((entry) => ({
137
+ uri: entry.uri || '',
138
+ address: entry.address || '',
139
+ port: entry.port || '',
140
+ local: toBool(entry.local),
141
+ relay: toBool(entry.relay),
142
+ protocol: entry.protocol || '',
143
+ }))
144
+ .map((entry) => {
145
+ if (!entry.uri && entry.address && entry.port && entry.protocol) {
146
+ return {
147
+ ...entry,
148
+ uri: `${entry.protocol}://${entry.address}:${entry.port}`,
149
+ };
150
+ }
151
+ return entry;
152
+ })
153
+ .filter((entry) => Boolean(entry.uri));
154
+
155
+ if (normalized.length === 0) {
156
+ return [];
157
+ }
158
+
159
+ const candidates = [];
160
+ for (const entry of normalized) {
161
+ candidates.push(entry);
162
+ if (entry.local && !entry.relay && entry.address && entry.port) {
163
+ candidates.push({
164
+ ...entry,
165
+ protocol: 'http',
166
+ uri: `http://${entry.address}:${entry.port}`,
167
+ });
168
+ }
169
+ }
170
+
171
+ const uniqueCandidates = [];
172
+ const seen = new Set();
173
+ for (const entry of candidates) {
174
+ if (!entry.uri || seen.has(entry.uri)) {
175
+ continue;
176
+ }
177
+ seen.add(entry.uri);
178
+ uniqueCandidates.push(entry);
179
+ }
180
+
181
+ uniqueCandidates.sort((a, b) => scoreConnection(a) - scoreConnection(b));
182
+
183
+ return uniqueCandidates.map((entry) => entry.uri).filter(Boolean);
184
+ }
185
+
186
+ function chooseBestConnection(connections) {
187
+ const candidates = chooseConnectionCandidates(connections);
188
+ return candidates[0] ?? null;
189
+ }
190
+
191
+ function extractMetadataList(container) {
192
+ if (!container || typeof container !== 'object') {
193
+ return [];
194
+ }
195
+
196
+ return asArray(container.Metadata ?? container.Directory ?? container.Video ?? container.Track ?? []);
197
+ }
198
+
199
+ function normalizeLibrarySection(section) {
200
+ return {
201
+ id: String(section.key ?? section.id ?? ''),
202
+ title: section.title || section.name || 'Untitled',
203
+ type: section.type || '',
204
+ };
205
+ }
206
+
207
+ export async function createPlexPin(config, { forwardUrl = null } = {}) {
208
+ const url = new URL('/api/v2/pins', PLEX_TV_BASE);
209
+ url.searchParams.set('strong', 'true');
210
+
211
+ const response = await fetch(url, {
212
+ method: 'POST',
213
+ headers: makePlexHeaders(config),
214
+ });
215
+
216
+ const payload = await ensureJson(response, 'Plex PIN creation');
217
+
218
+ const id = String(payload.id ?? '');
219
+ const code = String(payload.code ?? '');
220
+
221
+ if (!id || !code) {
222
+ throw new Error('Plex PIN payload missing id/code');
223
+ }
224
+
225
+ return {
226
+ id,
227
+ code,
228
+ authUrl: buildPlexPinAuthUrl(config, code, forwardUrl),
229
+ };
230
+ }
231
+
232
+ export function buildPlexPinAuthUrl(config, code, forwardUrl = null) {
233
+ const params = new URLSearchParams();
234
+ params.set('clientID', config.plexClientIdentifier);
235
+ params.set('code', code);
236
+ params.set('context[device][product]', config.plexProduct);
237
+ if (forwardUrl) {
238
+ params.set('forwardUrl', forwardUrl);
239
+ }
240
+
241
+ return `https://app.plex.tv/auth#?${params.toString()}`;
242
+ }
243
+
244
+ export async function pollPlexPin(config, { pinId, code }) {
245
+ const url = new URL(`/api/v2/pins/${encodeURIComponent(pinId)}`, PLEX_TV_BASE);
246
+ url.searchParams.set('code', code);
247
+
248
+ const response = await fetch(url, {
249
+ headers: makePlexHeaders(config),
250
+ });
251
+
252
+ const payload = await ensureJson(response, 'Plex PIN poll');
253
+
254
+ return {
255
+ authToken: payload.authToken || null,
256
+ expiresAt: payload.expiresAt || null,
257
+ expired: toBool(payload.expired),
258
+ };
259
+ }
260
+
261
+ export async function listPlexServers(config, plexToken) {
262
+ const url = new URL('/api/v2/resources', PLEX_CLIENTS_BASE);
263
+ url.searchParams.set('includeHttps', '1');
264
+ url.searchParams.set('includeRelay', '1');
265
+ url.searchParams.set('includeIPv6', '1');
266
+
267
+ const response = await fetch(url, {
268
+ headers: makePlexHeaders(config, plexToken),
269
+ });
270
+
271
+ const payload = await ensureJson(response, 'Plex resources lookup');
272
+ const resources = extractResources(payload);
273
+
274
+ return resources
275
+ .filter((resource) => {
276
+ const provides = String(resource.provides || '')
277
+ .split(',')
278
+ .map((v) => v.trim())
279
+ .filter(Boolean);
280
+ if (provides.includes('server')) {
281
+ return true;
282
+ }
283
+ return String(resource.product || '').toLowerCase() === 'plex media server';
284
+ })
285
+ .map((resource) => {
286
+ const machineId = String(resource.clientIdentifier ?? resource.machineIdentifier ?? resource.uuid ?? '');
287
+ const name = resource.name || resource.product || 'Plex Server';
288
+ const connections = asArray(resource.Connection ?? resource.connections ?? []);
289
+ const connectionUris = chooseConnectionCandidates(connections);
290
+ const baseUrl = chooseBestConnection(connections);
291
+ const accessToken = resource.accessToken || null;
292
+
293
+ return {
294
+ machineId,
295
+ name,
296
+ baseUrl,
297
+ connectionUris,
298
+ accessToken,
299
+ };
300
+ })
301
+ .filter((resource) => resource.machineId && resource.baseUrl);
302
+ }
303
+
304
+ function joinBaseAndPath(baseUrl, relativePath) {
305
+ const normalized = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
306
+ const pathPart = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
307
+ return new URL(pathPart, normalized);
308
+ }
309
+
310
+ function normalizePlexTokenCandidates(plexToken) {
311
+ if (Array.isArray(plexToken)) {
312
+ return [...new Set(plexToken.map((token) => String(token || '').trim()).filter(Boolean))];
313
+ }
314
+ const normalized = String(plexToken || '').trim();
315
+ return normalized ? [normalized] : [];
316
+ }
317
+
318
+ function buildPmsUrl(baseUrl, path, searchParams, plexToken) {
319
+ const url = joinBaseAndPath(baseUrl, path);
320
+
321
+ if (searchParams) {
322
+ for (const [key, value] of Object.entries(searchParams)) {
323
+ if (value == null) {
324
+ continue;
325
+ }
326
+ url.searchParams.set(key, String(value));
327
+ }
328
+ }
329
+
330
+ if (plexToken) {
331
+ url.searchParams.set('X-Plex-Token', plexToken);
332
+ }
333
+
334
+ return url;
335
+ }
336
+
337
+ async function fetchPmsJson(baseUrl, plexToken, path, searchParams = null, options = {}) {
338
+ const tokenCandidates = normalizePlexTokenCandidates(plexToken);
339
+ if (tokenCandidates.length === 0) {
340
+ throw new Error('PMS request failed: missing Plex token');
341
+ }
342
+
343
+ for (const [index, token] of tokenCandidates.entries()) {
344
+ const url = buildPmsUrl(baseUrl, path, searchParams, token);
345
+ const response = await fetch(url, {
346
+ headers: {
347
+ Accept: 'application/json',
348
+ 'X-Plex-Token': token,
349
+ },
350
+ signal: options.signal,
351
+ });
352
+
353
+ if (response.status === 401 && index < tokenCandidates.length - 1) {
354
+ await response.arrayBuffer().catch(() => {});
355
+ continue;
356
+ }
357
+
358
+ return ensureJson(response, `PMS request ${url.pathname}`);
359
+ }
360
+
361
+ throw new Error(`PMS request ${path} failed: unauthorized`);
362
+ }
363
+
364
+ function normalizeSectionFolderPath(rawPath, sectionId) {
365
+ const value = String(rawPath || '');
366
+ if (!value.startsWith(`/library/sections/${encodeURIComponent(String(sectionId))}/folder`)) {
367
+ return null;
368
+ }
369
+
370
+ try {
371
+ const parsed = new URL(value, 'http://local.invalid');
372
+ if (!parsed.pathname.startsWith(`/library/sections/${encodeURIComponent(String(sectionId))}/folder`)) {
373
+ return null;
374
+ }
375
+ return `${parsed.pathname}${parsed.search}`;
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ async function fetchPms(
382
+ baseUrl,
383
+ plexToken,
384
+ path,
385
+ { method = 'GET', searchParams = null, headers = {}, signal = undefined } = {},
386
+ ) {
387
+ const tokenCandidates = normalizePlexTokenCandidates(plexToken);
388
+ if (tokenCandidates.length === 0) {
389
+ throw new Error('PMS request failed: missing Plex token');
390
+ }
391
+
392
+ for (const [index, token] of tokenCandidates.entries()) {
393
+ const url = buildPmsUrl(baseUrl, path, searchParams, token);
394
+ const response = await fetch(url, {
395
+ method,
396
+ headers: {
397
+ Accept: 'application/json',
398
+ ...headers,
399
+ 'X-Plex-Token': token,
400
+ },
401
+ signal,
402
+ });
403
+
404
+ const body = await response.text();
405
+ if (response.status === 401 && index < tokenCandidates.length - 1) {
406
+ continue;
407
+ }
408
+ if (!response.ok) {
409
+ throw new Error(`PMS request ${url.pathname} failed (${response.status}): ${body.slice(0, 400)}`);
410
+ }
411
+
412
+ return body;
413
+ }
414
+
415
+ throw new Error(`PMS request ${path} failed: unauthorized`);
416
+ }
417
+
418
+ function buildMetadataUri(machineId, ids) {
419
+ const normalizedIds = asArray(ids)
420
+ .map((id) => String(id || '').trim())
421
+ .filter(Boolean);
422
+
423
+ if (normalizedIds.length === 0) {
424
+ return `server://${machineId}/com.plexapp.plugins.library/library/metadata/`;
425
+ }
426
+
427
+ const encodedIds = normalizedIds.map((id) => encodeURIComponent(id)).join(',');
428
+ return `server://${machineId}/com.plexapp.plugins.library/library/metadata/${encodedIds}`;
429
+ }
430
+
431
+ export async function scrobblePlexItem({ baseUrl, plexToken, itemId }) {
432
+ await fetchPms(baseUrl, plexToken, '/:/scrobble', {
433
+ searchParams: {
434
+ identifier: 'com.plexapp.plugins.library',
435
+ key: String(itemId),
436
+ },
437
+ });
438
+ }
439
+
440
+ export async function updatePlexPlaybackStatus({
441
+ baseUrl,
442
+ plexToken,
443
+ itemId,
444
+ state = 'playing',
445
+ positionMs = 0,
446
+ durationMs = null,
447
+ clientIdentifier = null,
448
+ clientName = 'Subsonic Client',
449
+ product = 'Plexsonic Bridge',
450
+ sessionId = null,
451
+ }) {
452
+ const normalizedState = (() => {
453
+ const value = String(state || '').toLowerCase();
454
+ if (value === 'paused' || value === 'stopped' || value === 'playing') {
455
+ return value;
456
+ }
457
+ return 'playing';
458
+ })();
459
+
460
+ const normalizedPosition = Number.isFinite(positionMs)
461
+ ? Math.max(0, Math.trunc(positionMs))
462
+ : 0;
463
+
464
+ const itemKey = `/library/metadata/${encodeURIComponent(String(itemId))}`;
465
+ const timelineHeaders = {
466
+ 'X-Plex-Product': String(product || 'Plexsonic Bridge'),
467
+ 'X-Plex-Client-Identifier': String(clientIdentifier || 'plexsonic-subsonic'),
468
+ 'X-Plex-Device-Name': String(clientName || 'Subsonic Client'),
469
+ 'X-Plex-Provides': 'player',
470
+ };
471
+
472
+ if (sessionId) {
473
+ timelineHeaders['X-Plex-Session-Identifier'] = String(sessionId);
474
+ }
475
+
476
+ const timelineParams = {
477
+ identifier: 'com.plexapp.plugins.library',
478
+ key: itemKey,
479
+ ratingKey: String(itemId),
480
+ state: normalizedState,
481
+ time: normalizedPosition,
482
+ hasMDE: 1,
483
+ };
484
+
485
+ if (Number.isFinite(durationMs) && durationMs > 0) {
486
+ timelineParams.duration = Math.max(0, Math.trunc(durationMs));
487
+ }
488
+
489
+ try {
490
+ await fetchPms(baseUrl, plexToken, '/:/timeline', {
491
+ searchParams: timelineParams,
492
+ headers: timelineHeaders,
493
+ });
494
+ return;
495
+ } catch {
496
+ await fetchPms(baseUrl, plexToken, '/:/progress', {
497
+ searchParams: {
498
+ identifier: 'com.plexapp.plugins.library',
499
+ key: String(itemId),
500
+ state: normalizedState,
501
+ time: normalizedPosition,
502
+ },
503
+ headers: timelineHeaders,
504
+ });
505
+ }
506
+ }
507
+
508
+ export async function ratePlexItem({ baseUrl, plexToken, itemId, rating }) {
509
+ await fetchPms(baseUrl, plexToken, '/:/rate', {
510
+ searchParams: {
511
+ identifier: 'com.plexapp.plugins.library',
512
+ key: String(itemId),
513
+ rating,
514
+ },
515
+ });
516
+ }
517
+
518
+ export async function createPlexPlaylist({ baseUrl, plexToken, machineId, title, itemIds = [] }) {
519
+ const body = await fetchPms(baseUrl, plexToken, '/playlists', {
520
+ method: 'POST',
521
+ searchParams: {
522
+ type: 'audio',
523
+ title,
524
+ smart: 0,
525
+ uri: buildMetadataUri(machineId, itemIds),
526
+ },
527
+ });
528
+
529
+ if (!body) {
530
+ return null;
531
+ }
532
+
533
+ const payload = JSON.parse(body);
534
+ return extractMetadataList(payload.MediaContainer)[0] || null;
535
+ }
536
+
537
+ export async function addItemsToPlexPlaylist({ baseUrl, plexToken, machineId, playlistId, itemIds }) {
538
+ const normalized = asArray(itemIds)
539
+ .map((id) => String(id || '').trim())
540
+ .filter(Boolean);
541
+
542
+ if (normalized.length === 0) {
543
+ return null;
544
+ }
545
+
546
+ const body = await fetchPms(baseUrl, plexToken, `/playlists/${encodeURIComponent(playlistId)}/items`, {
547
+ method: 'PUT',
548
+ searchParams: {
549
+ uri: buildMetadataUri(machineId, normalized),
550
+ },
551
+ });
552
+
553
+ if (!body) {
554
+ return null;
555
+ }
556
+
557
+ const payload = JSON.parse(body);
558
+ return extractMetadataList(payload.MediaContainer)[0] || null;
559
+ }
560
+
561
+ export async function renamePlexPlaylist({ baseUrl, plexToken, playlistId, title }) {
562
+ await fetchPms(baseUrl, plexToken, `/playlists/${encodeURIComponent(playlistId)}`, {
563
+ method: 'PUT',
564
+ searchParams: {
565
+ title,
566
+ },
567
+ });
568
+ }
569
+
570
+ export async function listPlexPlaylists({ baseUrl, plexToken }) {
571
+ const payload = await fetchPmsJson(baseUrl, plexToken, '/playlists', {
572
+ playlistType: 'audio',
573
+ });
574
+ return extractMetadataList(payload.MediaContainer);
575
+ }
576
+
577
+ export async function listPlexPlaylistItems({ baseUrl, plexToken, playlistId }) {
578
+ const payload = await fetchPmsJson(
579
+ baseUrl,
580
+ plexToken,
581
+ `/playlists/${encodeURIComponent(playlistId)}/items`,
582
+ );
583
+ return extractMetadataList(payload.MediaContainer);
584
+ }
585
+
586
+ export async function deletePlexPlaylist({ baseUrl, plexToken, playlistId }) {
587
+ await fetchPms(baseUrl, plexToken, `/playlists/${encodeURIComponent(playlistId)}`, {
588
+ method: 'DELETE',
589
+ });
590
+ }
591
+
592
+ export async function removePlexPlaylistItems({ baseUrl, plexToken, playlistId, playlistItemIds = [] }) {
593
+ const normalized = asArray(playlistItemIds)
594
+ .map((id) => String(id || '').trim())
595
+ .filter(Boolean);
596
+
597
+ if (normalized.length === 0) {
598
+ return;
599
+ }
600
+
601
+ await Promise.all(
602
+ normalized.map((itemId) =>
603
+ fetchPms(baseUrl, plexToken, `/playlists/${encodeURIComponent(playlistId)}/items/${encodeURIComponent(itemId)}`, {
604
+ method: 'DELETE',
605
+ }),
606
+ ),
607
+ );
608
+ }
609
+
610
+ export async function listMusicSections({ baseUrl, plexToken }) {
611
+ const payload = await fetchPmsJson(baseUrl, plexToken, '/library/sections');
612
+ const directories = asArray(payload.MediaContainer?.Directory ?? []);
613
+
614
+ return directories
615
+ .map(normalizeLibrarySection)
616
+ .filter((section) => section.id)
617
+ .filter((section) => section.type === 'artist' || section.type === 'music');
618
+ }
619
+
620
+ export async function listPlexSectionFolder({ baseUrl, plexToken, sectionId, folderPath = null }) {
621
+ const path = folderPath
622
+ ? normalizeSectionFolderPath(folderPath, sectionId)
623
+ : `/library/sections/${encodeURIComponent(sectionId)}/folder`;
624
+
625
+ if (!path) {
626
+ throw new Error('Invalid Plex folder path');
627
+ }
628
+
629
+ const payload = await fetchPmsJson(baseUrl, plexToken, path);
630
+ return {
631
+ container: payload?.MediaContainer || {},
632
+ items: extractMetadataList(payload?.MediaContainer),
633
+ };
634
+ }
635
+
636
+ export async function searchSectionMetadata({
637
+ baseUrl,
638
+ plexToken,
639
+ sectionId,
640
+ type = null,
641
+ query,
642
+ limit = 50,
643
+ offset = 0,
644
+ signal = undefined,
645
+ }) {
646
+ const normalizedQuery = String(query || '').trim();
647
+ if (!normalizedQuery) {
648
+ return [];
649
+ }
650
+
651
+ const normalizedLimit = Math.min(Math.max(Number.parseInt(String(limit), 10) || 50, 1), 500);
652
+ const normalizedOffset = Math.max(Number.parseInt(String(offset), 10) || 0, 0);
653
+
654
+ const searchParams = {
655
+ query: normalizedQuery,
656
+ 'X-Plex-Container-Start': normalizedOffset,
657
+ 'X-Plex-Container-Size': normalizedLimit,
658
+ };
659
+ if (type != null) {
660
+ searchParams.type = Number.parseInt(String(type), 10);
661
+ }
662
+
663
+ try {
664
+ const payload = await fetchPmsJson(
665
+ baseUrl,
666
+ plexToken,
667
+ `/library/sections/${encodeURIComponent(sectionId)}/search`,
668
+ searchParams,
669
+ { signal },
670
+ );
671
+ return extractMetadataList(payload.MediaContainer);
672
+ } catch (error) {
673
+ if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
674
+ throw error;
675
+ }
676
+ }
677
+
678
+ const fallbackParams = {
679
+ title: normalizedQuery,
680
+ 'X-Plex-Container-Start': normalizedOffset,
681
+ 'X-Plex-Container-Size': normalizedLimit,
682
+ };
683
+ if (type != null) {
684
+ fallbackParams.type = Number.parseInt(String(type), 10);
685
+ }
686
+
687
+ const fallbackPayload = await fetchPmsJson(
688
+ baseUrl,
689
+ plexToken,
690
+ `/library/sections/${encodeURIComponent(sectionId)}/all`,
691
+ fallbackParams,
692
+ { signal },
693
+ );
694
+ return extractMetadataList(fallbackPayload.MediaContainer);
695
+ }
696
+
697
+ function mergeUniqueByRatingKey(primary = [], secondary = []) {
698
+ const seen = new Set();
699
+ const out = [];
700
+
701
+ for (const item of [...primary, ...secondary]) {
702
+ const ratingKey = String(item?.ratingKey ?? '');
703
+ if (!ratingKey || seen.has(ratingKey)) {
704
+ continue;
705
+ }
706
+ seen.add(ratingKey);
707
+ out.push(item);
708
+ }
709
+
710
+ return out;
711
+ }
712
+
713
+ export async function searchSectionHubs({
714
+ baseUrl,
715
+ plexToken,
716
+ sectionId,
717
+ query,
718
+ limit = 100,
719
+ signal = undefined,
720
+ }) {
721
+ const normalizedQuery = String(query || '').trim();
722
+ if (!normalizedQuery) {
723
+ return {
724
+ artists: [],
725
+ albums: [],
726
+ tracks: [],
727
+ };
728
+ }
729
+
730
+ const normalizedLimit = Math.min(Math.max(Number.parseInt(String(limit), 10) || 100, 1), 500);
731
+ const payload = await fetchPmsJson(
732
+ baseUrl,
733
+ plexToken,
734
+ '/hubs/search',
735
+ {
736
+ query: normalizedQuery,
737
+ sectionId: String(sectionId),
738
+ limit: normalizedLimit,
739
+ includeExternalMedia: 0,
740
+ },
741
+ { signal },
742
+ );
743
+
744
+ const hubs = asArray(payload?.MediaContainer?.Hub ?? []);
745
+ let artists = [];
746
+ let albums = [];
747
+ let tracks = [];
748
+
749
+ for (const hub of hubs) {
750
+ const hubType = String(hub?.type || '').toLowerCase();
751
+ const metadata = extractMetadataList(hub);
752
+ if (metadata.length === 0) {
753
+ continue;
754
+ }
755
+
756
+ const artistsFromHub = metadata.filter((item) => String(item?.type || '').toLowerCase() === 'artist');
757
+ const albumsFromHub = metadata.filter((item) => String(item?.type || '').toLowerCase() === 'album');
758
+ const tracksFromHub = metadata.filter((item) => String(item?.type || '').toLowerCase() === 'track');
759
+
760
+ artists = mergeUniqueByRatingKey(
761
+ artists,
762
+ artistsFromHub.length > 0 ? artistsFromHub : hubType === 'artist' ? metadata : [],
763
+ );
764
+ albums = mergeUniqueByRatingKey(
765
+ albums,
766
+ albumsFromHub.length > 0 ? albumsFromHub : hubType === 'album' ? metadata : [],
767
+ );
768
+ tracks = mergeUniqueByRatingKey(
769
+ tracks,
770
+ tracksFromHub.length > 0 ? tracksFromHub : hubType === 'track' ? metadata : [],
771
+ );
772
+ }
773
+
774
+ return {
775
+ artists,
776
+ albums,
777
+ tracks,
778
+ };
779
+ }
780
+
781
+ export async function listArtists({ baseUrl, plexToken, sectionId }) {
782
+ const payload = await fetchPmsJson(baseUrl, plexToken, `/library/sections/${encodeURIComponent(sectionId)}/all`, {
783
+ type: 8,
784
+ });
785
+
786
+ return extractMetadataList(payload.MediaContainer);
787
+ }
788
+
789
+ export async function listAlbums({ baseUrl, plexToken, sectionId }) {
790
+ const payload = await fetchPmsJson(baseUrl, plexToken, `/library/sections/${encodeURIComponent(sectionId)}/all`, {
791
+ type: 9,
792
+ });
793
+
794
+ return extractMetadataList(payload.MediaContainer);
795
+ }
796
+
797
+ export async function listTracks({ baseUrl, plexToken, sectionId }) {
798
+ const payload = await fetchPmsJson(baseUrl, plexToken, `/library/sections/${encodeURIComponent(sectionId)}/all`, {
799
+ type: 10,
800
+ });
801
+
802
+ return extractMetadataList(payload.MediaContainer);
803
+ }
804
+
805
+ export async function getArtist({ baseUrl, plexToken, artistId }) {
806
+ const payload = await fetchPmsJson(baseUrl, plexToken, `/library/metadata/${encodeURIComponent(artistId)}`);
807
+ const item = extractMetadataList(payload.MediaContainer)[0] || null;
808
+ if (!item) {
809
+ return null;
810
+ }
811
+ return String(item.type || '').toLowerCase() === 'artist' ? item : null;
812
+ }
813
+
814
+ export async function listArtistAlbums({ baseUrl, plexToken, artistId }) {
815
+ const payload = await fetchPmsJson(baseUrl, plexToken, `/library/metadata/${encodeURIComponent(artistId)}/children`, {
816
+ type: 9,
817
+ });
818
+
819
+ return extractMetadataList(payload.MediaContainer);
820
+ }
821
+
822
+ export async function listArtistTracks({ baseUrl, plexToken, artistId }) {
823
+ const payload = await fetchPmsJson(baseUrl, plexToken, `/library/metadata/${encodeURIComponent(artistId)}/allLeaves`, {
824
+ type: 10,
825
+ });
826
+
827
+ return extractMetadataList(payload.MediaContainer);
828
+ }
829
+
830
+ export async function getAlbum({ baseUrl, plexToken, albumId }) {
831
+ const payload = await fetchPmsJson(baseUrl, plexToken, `/library/metadata/${encodeURIComponent(albumId)}`);
832
+ const item = extractMetadataList(payload.MediaContainer)[0] || null;
833
+ if (!item) {
834
+ return null;
835
+ }
836
+ return String(item.type || '').toLowerCase() === 'album' ? item : null;
837
+ }
838
+
839
+ export async function listAlbumTracks({ baseUrl, plexToken, albumId }) {
840
+ const payload = await fetchPmsJson(baseUrl, plexToken, `/library/metadata/${encodeURIComponent(albumId)}/children`, {
841
+ type: 10,
842
+ });
843
+
844
+ return extractMetadataList(payload.MediaContainer);
845
+ }
846
+
847
+ export async function getTrack({ baseUrl, plexToken, trackId, signal = undefined }) {
848
+ const payload = await fetchPmsJson(
849
+ baseUrl,
850
+ plexToken,
851
+ `/library/metadata/${encodeURIComponent(trackId)}`,
852
+ null,
853
+ { signal },
854
+ );
855
+ const item = extractMetadataList(payload.MediaContainer)[0] || null;
856
+ if (!item) {
857
+ return null;
858
+ }
859
+ return String(item.type || '').toLowerCase() === 'track' ? item : null;
860
+ }
861
+
862
+ async function fetchPmsText(baseUrl, plexToken, path, { searchParams = null, signal = undefined } = {}) {
863
+ const tokenCandidates = normalizePlexTokenCandidates(plexToken);
864
+ if (tokenCandidates.length === 0) {
865
+ throw new Error('PMS request failed: missing Plex token');
866
+ }
867
+
868
+ for (const [index, token] of tokenCandidates.entries()) {
869
+ const url = buildPmsUrl(baseUrl, path, searchParams, token);
870
+ const response = await fetch(url, {
871
+ headers: {
872
+ Accept: '*/*',
873
+ 'X-Plex-Token': token,
874
+ },
875
+ signal,
876
+ });
877
+
878
+ const body = await response.text();
879
+ if (response.status === 401 && index < tokenCandidates.length - 1) {
880
+ continue;
881
+ }
882
+ if (!response.ok) {
883
+ throw new Error(`PMS request ${url.pathname} failed (${response.status}): ${body.slice(0, 400)}`);
884
+ }
885
+
886
+ return body;
887
+ }
888
+
889
+ throw new Error(`PMS request ${path} failed: unauthorized`);
890
+ }
891
+
892
+ function normalizePlexKeyPath(key) {
893
+ const raw = String(key || '').trim();
894
+ if (!raw) {
895
+ return null;
896
+ }
897
+
898
+ try {
899
+ const parsed = new URL(raw);
900
+ return `${parsed.pathname}${parsed.search}`;
901
+ } catch {
902
+ return raw;
903
+ }
904
+ }
905
+
906
+ function decodeXmlEntities(value) {
907
+ const text = String(value || '');
908
+ return text
909
+ .replace(/&#x([0-9a-fA-F]+);/g, (_match, hex) => {
910
+ const code = Number.parseInt(hex, 16);
911
+ if (!Number.isFinite(code)) {
912
+ return _match;
913
+ }
914
+ try {
915
+ return String.fromCodePoint(code);
916
+ } catch {
917
+ return _match;
918
+ }
919
+ })
920
+ .replace(/&#([0-9]+);/g, (_match, dec) => {
921
+ const code = Number.parseInt(dec, 10);
922
+ if (!Number.isFinite(code)) {
923
+ return _match;
924
+ }
925
+ try {
926
+ return String.fromCodePoint(code);
927
+ } catch {
928
+ return _match;
929
+ }
930
+ })
931
+ .replaceAll('&amp;', '&')
932
+ .replaceAll('&lt;', '<')
933
+ .replaceAll('&gt;', '>')
934
+ .replaceAll('&quot;', '"')
935
+ .replaceAll('&apos;', "'");
936
+ }
937
+
938
+ function parseXmlAttrs(rawAttrs) {
939
+ const attrs = {};
940
+ const source = String(rawAttrs || '');
941
+ const attrPattern = /([A-Za-z0-9:_-]+)="([^"]*)"/g;
942
+ let match = null;
943
+ while ((match = attrPattern.exec(source)) !== null) {
944
+ attrs[match[1]] = decodeXmlEntities(match[2]);
945
+ }
946
+ return attrs;
947
+ }
948
+
949
+ function parseMs(value) {
950
+ const parsed = Number.parseInt(String(value ?? ''), 10);
951
+ if (!Number.isFinite(parsed) || parsed < 0) {
952
+ return undefined;
953
+ }
954
+ return parsed;
955
+ }
956
+
957
+ function parsePlexLyricsXml(xmlText) {
958
+ const xml = String(xmlText || '').trim();
959
+ if (!xml || !xml.includes('<Lyrics')) {
960
+ return [];
961
+ }
962
+
963
+ const lyricsPattern = /<Lyrics\b([^>]*)>([\s\S]*?)<\/Lyrics>/gi;
964
+ const out = [];
965
+ let lyricsMatch = null;
966
+
967
+ while ((lyricsMatch = lyricsPattern.exec(xml)) !== null) {
968
+ const lyricsAttrs = parseXmlAttrs(lyricsMatch[1]);
969
+ const lyricsInner = String(lyricsMatch[2] || '');
970
+ const timed = String(lyricsAttrs.timed || '') === '1' || String(lyricsAttrs.timed || '').toLowerCase() === 'true';
971
+ const lang = String(lyricsAttrs.lang || '').trim() || 'und';
972
+
973
+ const lines = [];
974
+ const linePattern = /<Line\b([^>]*?)(?:\/>|>([\s\S]*?)<\/Line>)/gi;
975
+ let lineMatch = null;
976
+
977
+ while ((lineMatch = linePattern.exec(lyricsInner)) !== null) {
978
+ const lineAttrs = parseXmlAttrs(lineMatch[1]);
979
+ const lineInner = String(lineMatch[2] || '');
980
+ const lineStart =
981
+ parseMs(lineAttrs.startOffset) ??
982
+ parseMs(lineAttrs.start) ??
983
+ parseMs(lineAttrs.time);
984
+
985
+ const spanPattern = /<Span\b([^>]*?)(?:\/>|>([\s\S]*?)<\/Span>)/gi;
986
+ const spanTexts = [];
987
+ let spanStart = lineStart;
988
+ let spanMatch = null;
989
+ while ((spanMatch = spanPattern.exec(lineInner)) !== null) {
990
+ const spanAttrs = parseXmlAttrs(spanMatch[1]);
991
+ const spanTextAttr = String(spanAttrs.text || '').trim();
992
+ const spanTextInner = decodeXmlEntities(String(spanMatch[2] || '').replace(/<[^>]*>/g, '')).trim();
993
+ const spanText = spanTextAttr || spanTextInner;
994
+ if (spanText) {
995
+ spanTexts.push(spanText);
996
+ }
997
+
998
+ const inferredSpanStart =
999
+ parseMs(spanAttrs.startOffset) ??
1000
+ parseMs(spanAttrs.start) ??
1001
+ parseMs(spanAttrs.time);
1002
+ if (spanStart === undefined && inferredSpanStart !== undefined) {
1003
+ spanStart = inferredSpanStart;
1004
+ }
1005
+ }
1006
+
1007
+ const text = spanTexts.join(' ').trim();
1008
+ if (!text) {
1009
+ continue;
1010
+ }
1011
+
1012
+ if (spanStart === undefined) {
1013
+ lines.push({ value: text });
1014
+ } else {
1015
+ lines.push({ value: text, start: spanStart });
1016
+ }
1017
+ }
1018
+
1019
+ if (lines.length === 0) {
1020
+ continue;
1021
+ }
1022
+
1023
+ out.push({
1024
+ lang,
1025
+ synced: timed || lines.some((line) => Number.isFinite(line.start)),
1026
+ lines,
1027
+ });
1028
+ }
1029
+
1030
+ return out;
1031
+ }
1032
+
1033
+ function extractLyricStreamKeysFromMetadataXml(xmlText) {
1034
+ const xml = String(xmlText || '');
1035
+ if (!xml) {
1036
+ return [];
1037
+ }
1038
+
1039
+ const keys = new Set();
1040
+ const pushKey = (raw) => {
1041
+ const normalized = normalizePlexKeyPath(raw);
1042
+ if (normalized && normalized.includes('/library/streams/')) {
1043
+ keys.add(normalized);
1044
+ }
1045
+ };
1046
+
1047
+ const streamPattern = /<Stream\b([^>]*?)(?:\/>|>)/gi;
1048
+ let streamMatch = null;
1049
+ while ((streamMatch = streamPattern.exec(xml)) !== null) {
1050
+ const attrs = parseXmlAttrs(streamMatch[1]);
1051
+ const streamType = Number.parseInt(String(attrs.streamType ?? ''), 10);
1052
+ const lowerType = String(attrs.type || '').toLowerCase();
1053
+ const lowerCodec = String(attrs.codec || '').toLowerCase();
1054
+ const lowerFormat = String(attrs.format || '').toLowerCase();
1055
+ const lowerTitle = String(attrs.title || attrs.displayTitle || '').toLowerCase();
1056
+ const looksLyric =
1057
+ streamType === 3 ||
1058
+ lowerType.includes('lyric') ||
1059
+ lowerCodec.includes('lrc') ||
1060
+ lowerFormat.includes('lrc') ||
1061
+ lowerTitle.includes('lyric');
1062
+
1063
+ if (looksLyric) {
1064
+ pushKey(attrs.key);
1065
+ const id = String(attrs.id || '').trim();
1066
+ if (/^\d+$/.test(id)) {
1067
+ pushKey(`/library/streams/${id}`);
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ // Some payload variants expose lyric stream id on parent elements instead of Stream key.
1073
+ const lyricAttrPattern = /\b([A-Za-z0-9:_-]*lyric[A-Za-z0-9:_-]*stream[A-Za-z0-9:_-]*)="([^"]+)"/gi;
1074
+ let lyricAttrMatch = null;
1075
+ while ((lyricAttrMatch = lyricAttrPattern.exec(xml)) !== null) {
1076
+ const value = String(lyricAttrMatch[2] || '').trim();
1077
+ if (/^\d+$/.test(value)) {
1078
+ pushKey(`/library/streams/${value}`);
1079
+ } else {
1080
+ pushKey(value);
1081
+ }
1082
+ }
1083
+
1084
+ return [...keys];
1085
+ }
1086
+
1087
+ function collectLyricHints(value, out, depth = 0, lyricContext = false) {
1088
+ if (value == null || depth > 8) {
1089
+ return;
1090
+ }
1091
+
1092
+ if (typeof value === 'string') {
1093
+ const text = value.trim();
1094
+ if (lyricContext && text) {
1095
+ out.push(text);
1096
+ }
1097
+ return;
1098
+ }
1099
+
1100
+ if (Array.isArray(value)) {
1101
+ for (const entry of value) {
1102
+ collectLyricHints(entry, out, depth + 1, lyricContext);
1103
+ }
1104
+ return;
1105
+ }
1106
+
1107
+ if (typeof value !== 'object') {
1108
+ return;
1109
+ }
1110
+
1111
+ const lowerType = String(value.type || '').toLowerCase();
1112
+ const lowerCodec = String(value.codec || '').toLowerCase();
1113
+ const lowerFormat = String(value.format || '').toLowerCase();
1114
+ const lowerTitle = String(value.title || value.displayTitle || '').toLowerCase();
1115
+ const isLyricishObject =
1116
+ lowerType.includes('lyric') ||
1117
+ lowerCodec.includes('lrc') ||
1118
+ lowerFormat.includes('lrc') ||
1119
+ lowerTitle.includes('lyric');
1120
+ const objectContext = lyricContext || isLyricishObject;
1121
+
1122
+ for (const [key, child] of Object.entries(value)) {
1123
+ const lowerKey = key.toLowerCase();
1124
+ if (
1125
+ lowerKey === 'lyrics' ||
1126
+ lowerKey === 'lyric' ||
1127
+ lowerKey === 'timedlyrics' ||
1128
+ lowerKey === 'structuredlyrics' ||
1129
+ lowerKey === 'line' ||
1130
+ lowerKey === 'lines'
1131
+ ) {
1132
+ collectLyricHints(child, out, depth + 1, true);
1133
+ continue;
1134
+ }
1135
+
1136
+ if (
1137
+ objectContext &&
1138
+ (lowerKey === 'text' || lowerKey === 'value' || lowerKey === 'line')
1139
+ ) {
1140
+ collectLyricHints(child, out, depth + 1, true);
1141
+ continue;
1142
+ }
1143
+
1144
+ if (typeof child === 'object' && child !== null) {
1145
+ collectLyricHints(child, out, depth + 1, objectContext);
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ function collectLyricStreamKeys(value, out, depth = 0) {
1151
+ if (value == null || depth > 8) {
1152
+ return;
1153
+ }
1154
+
1155
+ if (Array.isArray(value)) {
1156
+ for (const entry of value) {
1157
+ collectLyricStreamKeys(entry, out, depth + 1);
1158
+ }
1159
+ return;
1160
+ }
1161
+
1162
+ if (typeof value !== 'object') {
1163
+ return;
1164
+ }
1165
+
1166
+ const rawKey = normalizePlexKeyPath(value.key);
1167
+ const streamType = Number.parseInt(String(value.streamType ?? ''), 10);
1168
+ const lowerType = String(value.type || '').toLowerCase();
1169
+ const lowerCodec = String(value.codec || '').toLowerCase();
1170
+ const lowerFormat = String(value.format || '').toLowerCase();
1171
+ const lowerTitle = String(value.title || value.displayTitle || '').toLowerCase();
1172
+ const keyLooksLyric = rawKey && (rawKey.includes('/lyrics') || rawKey.includes('/library/streams/'));
1173
+ const objectLooksLyric =
1174
+ lowerType.includes('lyric') ||
1175
+ lowerCodec.includes('lrc') ||
1176
+ lowerFormat.includes('lrc') ||
1177
+ lowerTitle.includes('lyric') ||
1178
+ streamType === 3;
1179
+
1180
+ if (rawKey && (keyLooksLyric || objectLooksLyric)) {
1181
+ out.add(rawKey);
1182
+ }
1183
+
1184
+ for (const child of Object.values(value)) {
1185
+ if (typeof child === 'object' && child !== null) {
1186
+ collectLyricStreamKeys(child, out, depth + 1);
1187
+ }
1188
+ }
1189
+ }
1190
+
1191
+ export async function fetchPlexTrackLyricsCandidates({
1192
+ baseUrl,
1193
+ plexToken,
1194
+ trackId,
1195
+ signal = undefined,
1196
+ }) {
1197
+ const metadataPath = `/library/metadata/${encodeURIComponent(trackId)}`;
1198
+ const payloads = [];
1199
+ const candidates = [];
1200
+ const streamKeys = new Set();
1201
+ const streamKeysFromXml = new Set();
1202
+ const pushCandidate = (value) => {
1203
+ if (value == null) {
1204
+ return;
1205
+ }
1206
+ if (Array.isArray(value)) {
1207
+ for (const entry of value) {
1208
+ pushCandidate(entry);
1209
+ }
1210
+ return;
1211
+ }
1212
+ if (typeof value === 'string') {
1213
+ const text = value.trim();
1214
+ if (text) {
1215
+ candidates.push(text);
1216
+ }
1217
+ return;
1218
+ }
1219
+ candidates.push(value);
1220
+ };
1221
+
1222
+ const tryJsonPayload = async (path, searchParams = null) => {
1223
+ try {
1224
+ const payload = await fetchPmsJson(baseUrl, plexToken, path, searchParams, { signal });
1225
+ payloads.push(payload);
1226
+ return payload;
1227
+ } catch (error) {
1228
+ if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
1229
+ throw error;
1230
+ }
1231
+ return null;
1232
+ }
1233
+ };
1234
+
1235
+ const tryTextPayload = async (path, searchParams = null) => {
1236
+ try {
1237
+ const text = await fetchPmsText(baseUrl, plexToken, path, { searchParams, signal });
1238
+ if (!text || !text.trim()) {
1239
+ return;
1240
+ }
1241
+ const parsedXml = parsePlexLyricsXml(text);
1242
+ if (parsedXml.length > 0) {
1243
+ pushCandidate(parsedXml);
1244
+ } else {
1245
+ pushCandidate(text);
1246
+ }
1247
+ } catch (error) {
1248
+ if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
1249
+ throw error;
1250
+ }
1251
+ }
1252
+ };
1253
+
1254
+ await tryJsonPayload(metadataPath, {
1255
+ includeExternalMedia: 1,
1256
+ includeLyrics: 1,
1257
+ includePreferences: 1,
1258
+ asyncAugmentMetadata: 1,
1259
+ });
1260
+ await tryJsonPayload(`${metadataPath}/lyrics`, { format: 'json' });
1261
+ await tryJsonPayload(`${metadataPath}/lyrics`);
1262
+ await tryTextPayload(`${metadataPath}/lyrics`, { format: 'xml' });
1263
+
1264
+ // Explicitly parse metadata XML for lyric stream keys/id (more reliable than JSON shape).
1265
+ try {
1266
+ const metadataXml = await fetchPmsText(baseUrl, plexToken, metadataPath, {
1267
+ searchParams: {
1268
+ includeExternalMedia: 1,
1269
+ includeLyrics: 1,
1270
+ includePreferences: 1,
1271
+ format: 'xml',
1272
+ },
1273
+ signal,
1274
+ });
1275
+ for (const key of extractLyricStreamKeysFromMetadataXml(metadataXml)) {
1276
+ streamKeysFromXml.add(key);
1277
+ }
1278
+ } catch (error) {
1279
+ if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
1280
+ throw error;
1281
+ }
1282
+ }
1283
+
1284
+ for (const payload of payloads) {
1285
+ pushCandidate(payload?.MediaContainer?.Lyrics);
1286
+ pushCandidate(payload?.MediaContainer?.Lyric);
1287
+ pushCandidate(payload?.MediaContainer?.Metadata);
1288
+ pushCandidate(payload?.MediaContainer?.Track);
1289
+
1290
+ const hintValues = [];
1291
+ collectLyricHints(payload, hintValues);
1292
+ for (const hint of hintValues) {
1293
+ pushCandidate(hint);
1294
+ }
1295
+ }
1296
+
1297
+ for (const payload of payloads) {
1298
+ collectLyricStreamKeys(payload, streamKeys);
1299
+ }
1300
+ for (const key of streamKeysFromXml) {
1301
+ streamKeys.add(key);
1302
+ }
1303
+
1304
+ for (const key of streamKeys) {
1305
+ try {
1306
+ const text = await fetchPmsText(baseUrl, plexToken, key, {
1307
+ searchParams: { format: 'xml', 'X-Plex-Text-Format': 'plain' },
1308
+ signal,
1309
+ });
1310
+ if (text && text.trim()) {
1311
+ const parsedXml = parsePlexLyricsXml(text);
1312
+ if (parsedXml.length > 0) {
1313
+ pushCandidate(parsedXml);
1314
+ } else {
1315
+ pushCandidate(text);
1316
+ }
1317
+ }
1318
+ } catch (error) {
1319
+ if (error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
1320
+ throw error;
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ return candidates;
1326
+ }
1327
+
1328
+ export function buildPmsAssetUrl(baseUrl, plexToken, relativePath) {
1329
+ const primaryToken = normalizePlexTokenCandidates(plexToken)[0] || null;
1330
+ const url = joinBaseAndPath(baseUrl, relativePath);
1331
+ if (primaryToken) {
1332
+ url.searchParams.set('X-Plex-Token', primaryToken);
1333
+ }
1334
+ return url;
1335
+ }