plexsonic 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/plex.js +15 -0
- package/src/server.js +527 -63
- package/src/subsonic-xml.js +2 -0
package/package.json
CHANGED
package/src/plex.js
CHANGED
|
@@ -208,6 +208,7 @@ function normalizeLibrarySection(section) {
|
|
|
208
208
|
id: String(section.key ?? section.id ?? ''),
|
|
209
209
|
title: section.title || section.name || 'Untitled',
|
|
210
210
|
type: section.type || '',
|
|
211
|
+
scanning: toBool(section.refreshing) || toBool(section.scanning),
|
|
211
212
|
updatedAt: parseTimestamp(section.updatedAt),
|
|
212
213
|
scannedAt: parseTimestamp(section.scannedAt),
|
|
213
214
|
refreshedAt: parseTimestamp(section.refreshedAt),
|
|
@@ -629,6 +630,20 @@ export async function listMusicSections({ baseUrl, plexToken }) {
|
|
|
629
630
|
.filter((section) => section.type === 'artist' || section.type === 'music');
|
|
630
631
|
}
|
|
631
632
|
|
|
633
|
+
export async function startPlexSectionScan({ baseUrl, plexToken, sectionId, force = true }) {
|
|
634
|
+
await fetchPms(
|
|
635
|
+
baseUrl,
|
|
636
|
+
plexToken,
|
|
637
|
+
`/library/sections/${encodeURIComponent(sectionId)}/refresh`,
|
|
638
|
+
{
|
|
639
|
+
method: 'GET',
|
|
640
|
+
searchParams: {
|
|
641
|
+
force: force ? 1 : 0,
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
632
647
|
export async function listPlexSectionFolder({ baseUrl, plexToken, sectionId, folderPath = null }) {
|
|
633
648
|
const path = folderPath
|
|
634
649
|
? normalizeSectionFolderPath(folderPath, sectionId)
|
package/src/server.js
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
searchSectionHubs,
|
|
70
70
|
searchSectionMetadata,
|
|
71
71
|
scrobblePlexItem,
|
|
72
|
+
startPlexSectionScan,
|
|
72
73
|
updatePlexPlaybackStatus,
|
|
73
74
|
} from './plex.js';
|
|
74
75
|
import { createTokenCipher } from './token-crypto.js';
|
|
@@ -933,6 +934,7 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
|
|
|
933
934
|
[album?.parentTitle, fallbackArtistName],
|
|
934
935
|
'Unknown Artist',
|
|
935
936
|
);
|
|
937
|
+
const genre = firstGenreTag(album) || undefined;
|
|
936
938
|
|
|
937
939
|
return {
|
|
938
940
|
id: albumId,
|
|
@@ -948,6 +950,7 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
|
|
|
948
950
|
duration: durationSeconds(album.duration),
|
|
949
951
|
created: toIsoFromEpochSeconds(album.addedAt),
|
|
950
952
|
year: album.year,
|
|
953
|
+
genre,
|
|
951
954
|
...subsonicRatingAttrs(album),
|
|
952
955
|
};
|
|
953
956
|
}
|
|
@@ -1051,7 +1054,9 @@ function songAttrs(track, albumTitle = null, albumCoverArt = null, albumMetadata
|
|
|
1051
1054
|
[albumCoverArt, track?.parentRatingKey, track?.ratingKey],
|
|
1052
1055
|
undefined,
|
|
1053
1056
|
);
|
|
1054
|
-
const
|
|
1057
|
+
const albumGenreTags = albumMetadata ? allGenreTags(albumMetadata) : [];
|
|
1058
|
+
const genreTagsRaw = allGenreTags(track);
|
|
1059
|
+
const genreTags = genreTagsRaw.length > 0 ? genreTagsRaw : albumGenreTags;
|
|
1055
1060
|
const genre = genreTags[0] || undefined;
|
|
1056
1061
|
const genres = genreTags.length > 0 ? genreTags.join('; ') : undefined;
|
|
1057
1062
|
const discNumber = parsePositiveInt(track?.parentIndex ?? track?.discNumber, 0) || undefined;
|
|
@@ -1689,6 +1694,68 @@ function isAbortError(error) {
|
|
|
1689
1694
|
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
|
|
1690
1695
|
}
|
|
1691
1696
|
|
|
1697
|
+
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
|
1698
|
+
|
|
1699
|
+
function waitMs(ms) {
|
|
1700
|
+
return new Promise((resolve) => {
|
|
1701
|
+
setTimeout(resolve, ms);
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
async function fetchWithRetry({
|
|
1706
|
+
url,
|
|
1707
|
+
options = {},
|
|
1708
|
+
request = null,
|
|
1709
|
+
context = 'upstream request',
|
|
1710
|
+
maxAttempts = 3,
|
|
1711
|
+
baseDelayMs = 200,
|
|
1712
|
+
}) {
|
|
1713
|
+
let lastError = null;
|
|
1714
|
+
|
|
1715
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
1716
|
+
try {
|
|
1717
|
+
const response = await fetch(url, options);
|
|
1718
|
+
if (!RETRYABLE_UPSTREAM_STATUSES.has(response.status) || attempt >= maxAttempts) {
|
|
1719
|
+
return response;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
request?.log?.warn(
|
|
1723
|
+
{ context, status: response.status, attempt, maxAttempts },
|
|
1724
|
+
'Transient upstream failure, retrying',
|
|
1725
|
+
);
|
|
1726
|
+
|
|
1727
|
+
try {
|
|
1728
|
+
await response.body?.cancel?.();
|
|
1729
|
+
} catch {}
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
if (isAbortError(error)) {
|
|
1732
|
+
throw error;
|
|
1733
|
+
}
|
|
1734
|
+
lastError = error;
|
|
1735
|
+
if (attempt >= maxAttempts) {
|
|
1736
|
+
throw error;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
request?.log?.warn(
|
|
1740
|
+
{ context, attempt, maxAttempts, message: error?.message || String(error) },
|
|
1741
|
+
'Transient upstream error, retrying',
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
await waitMs(baseDelayMs * attempt);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
if (lastError) {
|
|
1749
|
+
throw lastError;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
throw new Error(`${context} failed after retries`);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function isPlexNotFoundError(error) {
|
|
1756
|
+
return String(error?.message || '').includes('(404)');
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1692
1759
|
function safeLower(value) {
|
|
1693
1760
|
return String(value || '').toLowerCase();
|
|
1694
1761
|
}
|
|
@@ -1983,6 +2050,24 @@ function resolvedGenreTagsForTrack(track, albumGenreTagMap) {
|
|
|
1983
2050
|
return Array.isArray(albumTags) ? albumTags : [];
|
|
1984
2051
|
}
|
|
1985
2052
|
|
|
2053
|
+
function withResolvedTrackGenres(track, albumGenreTagMap) {
|
|
2054
|
+
const resolvedTags = resolvedGenreTagsForTrack(track, albumGenreTagMap);
|
|
2055
|
+
if (resolvedTags.length === 0) {
|
|
2056
|
+
return track;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
const currentTags = allGenreTags(track);
|
|
2060
|
+
if (currentTags.length > 0) {
|
|
2061
|
+
return track;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
return {
|
|
2065
|
+
...track,
|
|
2066
|
+
Genre: resolvedTags.map((tag) => ({ tag })),
|
|
2067
|
+
genre: resolvedTags.join('; '),
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1986
2071
|
function firstGenreTag(item) {
|
|
1987
2072
|
const tags = allGenreTags(item);
|
|
1988
2073
|
return tags[0] || null;
|
|
@@ -2093,6 +2178,7 @@ function requiredPlexStateForSubsonic(reply, plexContext, tokenCipher) {
|
|
|
2093
2178
|
baseUrl: plexContext.server_base_url,
|
|
2094
2179
|
machineId: plexContext.machine_id,
|
|
2095
2180
|
musicSectionId: plexContext.music_section_id,
|
|
2181
|
+
musicSectionName: plexContext.music_section_name || null,
|
|
2096
2182
|
serverName: plexContext.server_name || 'Plex Music',
|
|
2097
2183
|
};
|
|
2098
2184
|
}
|
|
@@ -2260,11 +2346,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2260
2346
|
});
|
|
2261
2347
|
|
|
2262
2348
|
const playbackSessions = new Map();
|
|
2349
|
+
const savedPlayQueues = new Map();
|
|
2263
2350
|
const PLAYBACK_RECONCILE_INTERVAL_MS = 15000;
|
|
2264
2351
|
const PLAYBACK_IDLE_TIMEOUT_MS = 120000;
|
|
2265
2352
|
const STREAM_DISCONNECT_STOP_DELAY_MS = 4000;
|
|
2266
2353
|
const PLAYBACK_MAX_DISCONNECT_WAIT_MS = 30 * 60 * 1000;
|
|
2267
2354
|
const STREAM_PROGRESS_HEARTBEAT_MS = 10000;
|
|
2355
|
+
const PLAY_QUEUE_IDLE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
2268
2356
|
const activeSearchRequests = new Map();
|
|
2269
2357
|
const searchBrowseCache = new Map();
|
|
2270
2358
|
const SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS = 15000;
|
|
@@ -2395,12 +2483,21 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2395
2483
|
}
|
|
2396
2484
|
|
|
2397
2485
|
async function loadLibraryTracksRaw({ plexState }) {
|
|
2398
|
-
const
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2486
|
+
const [tracks, albums] = await Promise.all([
|
|
2487
|
+
listTracks({
|
|
2488
|
+
baseUrl: plexState.baseUrl,
|
|
2489
|
+
plexToken: plexState.plexToken,
|
|
2490
|
+
sectionId: plexState.musicSectionId,
|
|
2491
|
+
}),
|
|
2492
|
+
listAlbums({
|
|
2493
|
+
baseUrl: plexState.baseUrl,
|
|
2494
|
+
plexToken: plexState.plexToken,
|
|
2495
|
+
sectionId: plexState.musicSectionId,
|
|
2496
|
+
}),
|
|
2497
|
+
]);
|
|
2498
|
+
const albumGenreTagMap = buildAlbumGenreTagMap(albums);
|
|
2499
|
+
const enrichedTracks = tracks.map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
|
|
2500
|
+
return sortTracksForLibraryBrowse(enrichedTracks);
|
|
2404
2501
|
}
|
|
2405
2502
|
|
|
2406
2503
|
function getLibraryCollectionLoader(collection, plexState) {
|
|
@@ -2855,6 +2952,90 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2855
2952
|
});
|
|
2856
2953
|
}
|
|
2857
2954
|
|
|
2955
|
+
function pruneSavedPlayQueues(now = Date.now()) {
|
|
2956
|
+
for (const [key, value] of savedPlayQueues.entries()) {
|
|
2957
|
+
if (!value || (now - Number(value.updatedAt || 0)) > PLAY_QUEUE_IDLE_TTL_MS) {
|
|
2958
|
+
savedPlayQueues.delete(key);
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
function playQueueClientKey(request) {
|
|
2964
|
+
const rawClient =
|
|
2965
|
+
getRequestParam(request, 'c') ||
|
|
2966
|
+
String(request.headers?.['user-agent'] || '').trim() ||
|
|
2967
|
+
'subsonic-client';
|
|
2968
|
+
return safeLower(rawClient).slice(0, 128) || 'subsonic-client';
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
function playQueueStorageKey(accountId, request) {
|
|
2972
|
+
return `${accountId}:${playQueueClientKey(request)}`;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
function requestedPlayQueueItemIds(request) {
|
|
2976
|
+
const ids = getRequestParamValues(request, 'id');
|
|
2977
|
+
if (ids.length > 0) {
|
|
2978
|
+
return ids;
|
|
2979
|
+
}
|
|
2980
|
+
return getRequestParamValues(request, 'songId');
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
async function resolveTracksByIdOrder({ accountId, plexState, request, ids }) {
|
|
2984
|
+
const orderedIds = (Array.isArray(ids) ? ids : [])
|
|
2985
|
+
.map((id) => String(id || '').trim())
|
|
2986
|
+
.filter(Boolean);
|
|
2987
|
+
if (orderedIds.length === 0) {
|
|
2988
|
+
return [];
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
const tracks = await getCachedLibraryTracks({ accountId, plexState, request });
|
|
2992
|
+
const cachedById = new Map();
|
|
2993
|
+
for (const track of tracks) {
|
|
2994
|
+
const ratingKey = String(track?.ratingKey || '').trim();
|
|
2995
|
+
if (ratingKey && !cachedById.has(ratingKey)) {
|
|
2996
|
+
cachedById.set(ratingKey, track);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
const missingIds = [];
|
|
3001
|
+
for (const id of orderedIds) {
|
|
3002
|
+
if (!cachedById.has(id)) {
|
|
3003
|
+
missingIds.push(id);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
if (missingIds.length > 0) {
|
|
3008
|
+
const fetched = await Promise.all(
|
|
3009
|
+
missingIds.map(async (trackId) => {
|
|
3010
|
+
try {
|
|
3011
|
+
const track = await getTrack({
|
|
3012
|
+
baseUrl: plexState.baseUrl,
|
|
3013
|
+
plexToken: plexState.plexToken,
|
|
3014
|
+
trackId,
|
|
3015
|
+
});
|
|
3016
|
+
return track || null;
|
|
3017
|
+
} catch {
|
|
3018
|
+
return null;
|
|
3019
|
+
}
|
|
3020
|
+
}),
|
|
3021
|
+
);
|
|
3022
|
+
|
|
3023
|
+
const nonNullFetched = fetched.filter(Boolean);
|
|
3024
|
+
applyCachedRatingOverridesForAccount({ accountId, plexState, items: nonNullFetched });
|
|
3025
|
+
|
|
3026
|
+
for (const track of nonNullFetched) {
|
|
3027
|
+
const ratingKey = String(track?.ratingKey || '').trim();
|
|
3028
|
+
if (ratingKey && !cachedById.has(ratingKey)) {
|
|
3029
|
+
cachedById.set(ratingKey, track);
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
return orderedIds
|
|
3035
|
+
.map((id) => cachedById.get(id))
|
|
3036
|
+
.filter(Boolean);
|
|
3037
|
+
}
|
|
3038
|
+
|
|
2858
3039
|
function applyCachedRatingOverridesForAccount({ accountId, plexState, items }) {
|
|
2859
3040
|
if (!Array.isArray(items) || items.length === 0) {
|
|
2860
3041
|
return 0;
|
|
@@ -2867,6 +3048,62 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2867
3048
|
return applyRatingOverridesToItems(entry, items, Date.now());
|
|
2868
3049
|
}
|
|
2869
3050
|
|
|
3051
|
+
async function resolveArtistFromCachedLibrary({ accountId, plexState, request, artistId }) {
|
|
3052
|
+
const normalizedArtistId = String(artistId || '').trim();
|
|
3053
|
+
if (!normalizedArtistId) {
|
|
3054
|
+
return null;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
const [artists, albums, tracks] = await Promise.all([
|
|
3058
|
+
getCachedLibraryArtists({ accountId, plexState, request }),
|
|
3059
|
+
getCachedLibraryAlbums({ accountId, plexState, request }),
|
|
3060
|
+
getCachedLibraryTracks({ accountId, plexState, request }),
|
|
3061
|
+
]);
|
|
3062
|
+
|
|
3063
|
+
const cachedArtist = artists.find((item) => String(item?.ratingKey || '').trim() === normalizedArtistId) || null;
|
|
3064
|
+
const cachedAlbums = albums.filter((item) => String(item?.parentRatingKey || '').trim() === normalizedArtistId);
|
|
3065
|
+
const artistTracks = tracks.filter((item) => String(item?.grandparentRatingKey || '').trim() === normalizedArtistId);
|
|
3066
|
+
|
|
3067
|
+
if (!cachedArtist && cachedAlbums.length === 0 && artistTracks.length === 0) {
|
|
3068
|
+
return null;
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
const artistName = cachedArtist?.title ||
|
|
3072
|
+
firstNonEmptyText(artistTracks.map((item) => item?.grandparentTitle), `Artist ${normalizedArtistId}`);
|
|
3073
|
+
|
|
3074
|
+
const artist = cachedArtist || {
|
|
3075
|
+
ratingKey: normalizedArtistId,
|
|
3076
|
+
title: artistName,
|
|
3077
|
+
addedAt: artistTracks[0]?.addedAt,
|
|
3078
|
+
updatedAt: artistTracks[0]?.updatedAt,
|
|
3079
|
+
};
|
|
3080
|
+
|
|
3081
|
+
const resolvedAlbums = cachedAlbums.length > 0
|
|
3082
|
+
? cachedAlbums
|
|
3083
|
+
: deriveAlbumsFromTracks(artistTracks, normalizedArtistId, artistName);
|
|
3084
|
+
|
|
3085
|
+
return {
|
|
3086
|
+
artist,
|
|
3087
|
+
albums: resolvedAlbums,
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
function scanStatusAttrsFromSection(section, { fallbackScanning = false } = {}) {
|
|
3092
|
+
return {
|
|
3093
|
+
scanning: Boolean(section?.scanning ?? fallbackScanning),
|
|
3094
|
+
count: parseNonNegativeInt(section?.leafCount, 0),
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
async function getMusicSectionScanStatus(plexState) {
|
|
3099
|
+
const sections = await listMusicSections({
|
|
3100
|
+
baseUrl: plexState.baseUrl,
|
|
3101
|
+
plexToken: plexState.plexToken,
|
|
3102
|
+
});
|
|
3103
|
+
const section = sections.find((item) => String(item?.id || '') === String(plexState.musicSectionId || ''));
|
|
3104
|
+
return section || null;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
2870
3107
|
function beginSearchRequest(request, accountId) {
|
|
2871
3108
|
const clientName =
|
|
2872
3109
|
getRequestParam(request, 'c') ||
|
|
@@ -3797,10 +4034,17 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3797
4034
|
return;
|
|
3798
4035
|
}
|
|
3799
4036
|
|
|
4037
|
+
const rawSectionId = String(plexState.musicSectionId || '').trim();
|
|
4038
|
+
const musicFolderId = /^\d+$/.test(rawSectionId) ? Number(rawSectionId) : rawSectionId;
|
|
4039
|
+
const musicFolderName =
|
|
4040
|
+
String(plexState.musicSectionName || '').trim() ||
|
|
4041
|
+
String(plexState.serverName || '').trim() ||
|
|
4042
|
+
'Music';
|
|
4043
|
+
|
|
3800
4044
|
const inner = node(
|
|
3801
4045
|
'musicFolders',
|
|
3802
4046
|
{},
|
|
3803
|
-
emptyNode('musicFolder', { id:
|
|
4047
|
+
emptyNode('musicFolder', { id: musicFolderId, name: musicFolderName }),
|
|
3804
4048
|
);
|
|
3805
4049
|
return sendSubsonicOk(reply, inner);
|
|
3806
4050
|
});
|
|
@@ -3839,7 +4083,67 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3839
4083
|
return;
|
|
3840
4084
|
}
|
|
3841
4085
|
|
|
3842
|
-
|
|
4086
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
4087
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
4088
|
+
if (!plexState) {
|
|
4089
|
+
return;
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
try {
|
|
4093
|
+
const sessions = [...playbackSessions.values()]
|
|
4094
|
+
.filter((session) =>
|
|
4095
|
+
session &&
|
|
4096
|
+
session.accountId === account.id &&
|
|
4097
|
+
session.itemId &&
|
|
4098
|
+
session.state !== 'stopped',
|
|
4099
|
+
)
|
|
4100
|
+
.sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0));
|
|
4101
|
+
|
|
4102
|
+
if (sessions.length === 0) {
|
|
4103
|
+
return sendSubsonicOk(reply, node('nowPlaying'));
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
const dedupedByItemId = new Map();
|
|
4107
|
+
for (const session of sessions) {
|
|
4108
|
+
const itemId = String(session.itemId || '').trim();
|
|
4109
|
+
if (itemId && !dedupedByItemId.has(itemId)) {
|
|
4110
|
+
dedupedByItemId.set(itemId, session);
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
|
|
4114
|
+
const itemIds = [...dedupedByItemId.keys()];
|
|
4115
|
+
const tracks = await resolveTracksByIdOrder({
|
|
4116
|
+
accountId: account.id,
|
|
4117
|
+
plexState,
|
|
4118
|
+
request,
|
|
4119
|
+
ids: itemIds,
|
|
4120
|
+
});
|
|
4121
|
+
|
|
4122
|
+
const sessionByTrackId = new Map(
|
|
4123
|
+
[...dedupedByItemId.entries()].map(([id, session]) => [id, session]),
|
|
4124
|
+
);
|
|
4125
|
+
const entries = tracks
|
|
4126
|
+
.map((track) => {
|
|
4127
|
+
const trackId = String(track?.ratingKey || '').trim();
|
|
4128
|
+
const session = sessionByTrackId.get(trackId);
|
|
4129
|
+
const minutesAgo = Math.max(
|
|
4130
|
+
0,
|
|
4131
|
+
Math.floor((Date.now() - Number(session?.updatedAt || Date.now())) / 60000),
|
|
4132
|
+
);
|
|
4133
|
+
return emptyNode('entry', {
|
|
4134
|
+
...songAttrs(track),
|
|
4135
|
+
username: account.username,
|
|
4136
|
+
playerId: session?.clientIdentifier || undefined,
|
|
4137
|
+
minutesAgo,
|
|
4138
|
+
});
|
|
4139
|
+
})
|
|
4140
|
+
.join('');
|
|
4141
|
+
|
|
4142
|
+
return sendSubsonicOk(reply, node('nowPlaying', {}, entries));
|
|
4143
|
+
} catch (error) {
|
|
4144
|
+
request.log.error(error, 'Failed to load now playing entries');
|
|
4145
|
+
return sendSubsonicError(reply, 10, 'Failed to load now playing');
|
|
4146
|
+
}
|
|
3843
4147
|
});
|
|
3844
4148
|
|
|
3845
4149
|
app.get('/rest/getScanStatus.view', async (request, reply) => {
|
|
@@ -3848,13 +4152,22 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3848
4152
|
return;
|
|
3849
4153
|
}
|
|
3850
4154
|
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
4155
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
4156
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
4157
|
+
if (!plexState) {
|
|
4158
|
+
return;
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
try {
|
|
4162
|
+
const section = await getMusicSectionScanStatus(plexState);
|
|
4163
|
+
return sendSubsonicOk(
|
|
4164
|
+
reply,
|
|
4165
|
+
emptyNode('scanStatus', scanStatusAttrsFromSection(section)),
|
|
4166
|
+
);
|
|
4167
|
+
} catch (error) {
|
|
4168
|
+
request.log.error(error, 'Failed to load scan status from Plex');
|
|
4169
|
+
return sendSubsonicError(reply, 10, 'Failed to load scan status');
|
|
4170
|
+
}
|
|
3858
4171
|
});
|
|
3859
4172
|
|
|
3860
4173
|
app.get('/rest/startScan.view', async (request, reply) => {
|
|
@@ -3863,13 +4176,31 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3863
4176
|
return;
|
|
3864
4177
|
}
|
|
3865
4178
|
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
4179
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
4180
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
4181
|
+
if (!plexState) {
|
|
4182
|
+
return;
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
try {
|
|
4186
|
+
await startPlexSectionScan({
|
|
4187
|
+
baseUrl: plexState.baseUrl,
|
|
4188
|
+
plexToken: plexState.plexToken,
|
|
4189
|
+
sectionId: plexState.musicSectionId,
|
|
4190
|
+
force: true,
|
|
4191
|
+
});
|
|
4192
|
+
|
|
4193
|
+
markSearchBrowseCacheDirty(searchBrowseCacheKey(account.id, plexState));
|
|
4194
|
+
|
|
4195
|
+
const section = await getMusicSectionScanStatus(plexState);
|
|
4196
|
+
return sendSubsonicOk(
|
|
4197
|
+
reply,
|
|
4198
|
+
emptyNode('scanStatus', scanStatusAttrsFromSection(section, { fallbackScanning: true })),
|
|
4199
|
+
);
|
|
4200
|
+
} catch (error) {
|
|
4201
|
+
request.log.error(error, 'Failed to trigger Plex scan');
|
|
4202
|
+
return sendSubsonicError(reply, 10, 'Failed to trigger scan');
|
|
4203
|
+
}
|
|
3873
4204
|
});
|
|
3874
4205
|
|
|
3875
4206
|
app.get('/rest/getStarred.view', async (request, reply) => {
|
|
@@ -4073,7 +4404,8 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4073
4404
|
);
|
|
4074
4405
|
|
|
4075
4406
|
const sortedSongs = sortTracksForLibraryBrowse(matchedSongs);
|
|
4076
|
-
const page = takePage(sortedSongs, offset, count)
|
|
4407
|
+
const page = takePage(sortedSongs, offset, count)
|
|
4408
|
+
.map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
|
|
4077
4409
|
const songXml = page.map((track) => emptyNode('song', songAttrs(track))).join('');
|
|
4078
4410
|
return sendSubsonicOk(reply, node('songsByGenre', {}, songXml));
|
|
4079
4411
|
} catch (error) {
|
|
@@ -4337,7 +4669,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4337
4669
|
}
|
|
4338
4670
|
|
|
4339
4671
|
try {
|
|
4340
|
-
|
|
4672
|
+
let track = await getTrack({
|
|
4341
4673
|
baseUrl: plexState.baseUrl,
|
|
4342
4674
|
plexToken: plexState.plexToken,
|
|
4343
4675
|
trackId: id,
|
|
@@ -4345,6 +4677,25 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4345
4677
|
if (!track) {
|
|
4346
4678
|
return sendSubsonicError(reply, 70, 'Song not found');
|
|
4347
4679
|
}
|
|
4680
|
+
|
|
4681
|
+
if (!firstGenreTag(track)) {
|
|
4682
|
+
const albumId = String(track?.parentRatingKey || '').trim();
|
|
4683
|
+
if (albumId) {
|
|
4684
|
+
try {
|
|
4685
|
+
const album = await getAlbum({
|
|
4686
|
+
baseUrl: plexState.baseUrl,
|
|
4687
|
+
plexToken: plexState.plexToken,
|
|
4688
|
+
albumId,
|
|
4689
|
+
});
|
|
4690
|
+
if (album) {
|
|
4691
|
+
const albumGenreTagMap = buildAlbumGenreTagMap([album]);
|
|
4692
|
+
track = withResolvedTrackGenres(track, albumGenreTagMap);
|
|
4693
|
+
}
|
|
4694
|
+
} catch (error) {
|
|
4695
|
+
request.log.debug(error, 'Failed to enrich song genre from album metadata');
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4348
4699
|
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [track] });
|
|
4349
4700
|
|
|
4350
4701
|
return sendSubsonicOk(reply, node('song', songAttrs(track)));
|
|
@@ -4910,17 +5261,54 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4910
5261
|
return;
|
|
4911
5262
|
}
|
|
4912
5263
|
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
5264
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
5265
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
5266
|
+
if (!plexState) {
|
|
5267
|
+
return;
|
|
5268
|
+
}
|
|
5269
|
+
|
|
5270
|
+
pruneSavedPlayQueues();
|
|
5271
|
+
const queueKey = playQueueStorageKey(account.id, request);
|
|
5272
|
+
const queueState = savedPlayQueues.get(queueKey) || {
|
|
5273
|
+
ids: [],
|
|
5274
|
+
current: '',
|
|
5275
|
+
position: 0,
|
|
5276
|
+
updatedAt: Date.now(),
|
|
5277
|
+
};
|
|
5278
|
+
|
|
5279
|
+
try {
|
|
5280
|
+
const effectiveIds = queueState.current && !queueState.ids.includes(queueState.current)
|
|
5281
|
+
? [queueState.current, ...queueState.ids]
|
|
5282
|
+
: queueState.ids;
|
|
5283
|
+
const tracks = await resolveTracksByIdOrder({
|
|
5284
|
+
accountId: account.id,
|
|
5285
|
+
plexState,
|
|
5286
|
+
request,
|
|
5287
|
+
ids: effectiveIds,
|
|
5288
|
+
});
|
|
5289
|
+
|
|
5290
|
+
const entries = tracks
|
|
5291
|
+
.map((track) => emptyNode('entry', songAttrs(track)))
|
|
5292
|
+
.join('');
|
|
5293
|
+
const changedIso = new Date(Number(queueState.updatedAt || Date.now())).toISOString();
|
|
5294
|
+
|
|
5295
|
+
return sendSubsonicOk(
|
|
5296
|
+
reply,
|
|
5297
|
+
node(
|
|
5298
|
+
'playQueue',
|
|
5299
|
+
{
|
|
5300
|
+
current: queueState.current || '',
|
|
5301
|
+
position: parseNonNegativeInt(queueState.position, 0),
|
|
5302
|
+
username: account.username,
|
|
5303
|
+
changed: changedIso,
|
|
5304
|
+
},
|
|
5305
|
+
entries,
|
|
5306
|
+
),
|
|
5307
|
+
);
|
|
5308
|
+
} catch (error) {
|
|
5309
|
+
request.log.error(error, 'Failed to load saved play queue');
|
|
5310
|
+
return sendSubsonicError(reply, 10, 'Failed to load play queue');
|
|
5311
|
+
}
|
|
4924
5312
|
});
|
|
4925
5313
|
|
|
4926
5314
|
app.get('/rest/savePlayQueue.view', async (request, reply) => {
|
|
@@ -4929,6 +5317,21 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4929
5317
|
return;
|
|
4930
5318
|
}
|
|
4931
5319
|
|
|
5320
|
+
const ids = requestedPlayQueueItemIds(request);
|
|
5321
|
+
const explicitCurrent = String(getRequestParam(request, 'current') || '').trim();
|
|
5322
|
+
const current = explicitCurrent || ids[0] || '';
|
|
5323
|
+
const position = parseNonNegativeInt(getRequestParam(request, 'position'), 0);
|
|
5324
|
+
const queueKey = playQueueStorageKey(account.id, request);
|
|
5325
|
+
const now = Date.now();
|
|
5326
|
+
|
|
5327
|
+
savedPlayQueues.set(queueKey, {
|
|
5328
|
+
ids,
|
|
5329
|
+
current,
|
|
5330
|
+
position,
|
|
5331
|
+
updatedAt: now,
|
|
5332
|
+
});
|
|
5333
|
+
pruneSavedPlayQueues(now);
|
|
5334
|
+
|
|
4932
5335
|
return sendSubsonicOk(reply);
|
|
4933
5336
|
});
|
|
4934
5337
|
|
|
@@ -5651,32 +6054,51 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5651
6054
|
}
|
|
5652
6055
|
|
|
5653
6056
|
try {
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
plexToken: plexState.plexToken,
|
|
5657
|
-
artistId,
|
|
5658
|
-
});
|
|
6057
|
+
let artist = null;
|
|
6058
|
+
let finalAlbums = [];
|
|
5659
6059
|
|
|
5660
|
-
|
|
5661
|
-
|
|
6060
|
+
try {
|
|
6061
|
+
artist = await getArtist({
|
|
6062
|
+
baseUrl: plexState.baseUrl,
|
|
6063
|
+
plexToken: plexState.plexToken,
|
|
6064
|
+
artistId,
|
|
6065
|
+
});
|
|
6066
|
+
} catch (error) {
|
|
6067
|
+
if (!isPlexNotFoundError(error)) {
|
|
6068
|
+
throw error;
|
|
6069
|
+
}
|
|
5662
6070
|
}
|
|
5663
|
-
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
|
|
5664
6071
|
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
artistId,
|
|
5669
|
-
});
|
|
5670
|
-
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
|
|
5671
|
-
|
|
5672
|
-
let finalAlbums = albums;
|
|
5673
|
-
if (finalAlbums.length === 0) {
|
|
5674
|
-
const tracks = await listArtistTracks({
|
|
6072
|
+
if (artist) {
|
|
6073
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
|
|
6074
|
+
const albums = await listArtistAlbums({
|
|
5675
6075
|
baseUrl: plexState.baseUrl,
|
|
5676
6076
|
plexToken: plexState.plexToken,
|
|
5677
6077
|
artistId,
|
|
5678
6078
|
});
|
|
5679
|
-
|
|
6079
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
|
|
6080
|
+
finalAlbums = albums;
|
|
6081
|
+
if (finalAlbums.length === 0) {
|
|
6082
|
+
const tracks = await listArtistTracks({
|
|
6083
|
+
baseUrl: plexState.baseUrl,
|
|
6084
|
+
plexToken: plexState.plexToken,
|
|
6085
|
+
artistId,
|
|
6086
|
+
});
|
|
6087
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
|
|
6088
|
+
finalAlbums = deriveAlbumsFromTracks(tracks, artist.ratingKey, artist.title);
|
|
6089
|
+
}
|
|
6090
|
+
} else {
|
|
6091
|
+
const fallback = await resolveArtistFromCachedLibrary({
|
|
6092
|
+
accountId: account.id,
|
|
6093
|
+
plexState,
|
|
6094
|
+
request,
|
|
6095
|
+
artistId,
|
|
6096
|
+
});
|
|
6097
|
+
if (!fallback?.artist) {
|
|
6098
|
+
return sendSubsonicError(reply, 70, 'Artist not found');
|
|
6099
|
+
}
|
|
6100
|
+
artist = fallback.artist;
|
|
6101
|
+
finalAlbums = fallback.albums || [];
|
|
5680
6102
|
}
|
|
5681
6103
|
|
|
5682
6104
|
const albumXml = finalAlbums
|
|
@@ -5882,11 +6304,18 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5882
6304
|
);
|
|
5883
6305
|
}
|
|
5884
6306
|
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
6307
|
+
let artist = null;
|
|
6308
|
+
try {
|
|
6309
|
+
artist = await getArtist({
|
|
6310
|
+
baseUrl: plexState.baseUrl,
|
|
6311
|
+
plexToken: plexState.plexToken,
|
|
6312
|
+
artistId: id,
|
|
6313
|
+
});
|
|
6314
|
+
} catch (error) {
|
|
6315
|
+
if (!isPlexNotFoundError(error)) {
|
|
6316
|
+
throw error;
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
5890
6319
|
|
|
5891
6320
|
if (artist) {
|
|
5892
6321
|
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
|
|
@@ -5925,6 +6354,34 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5925
6354
|
);
|
|
5926
6355
|
}
|
|
5927
6356
|
|
|
6357
|
+
if (!artist) {
|
|
6358
|
+
const fallback = await resolveArtistFromCachedLibrary({
|
|
6359
|
+
accountId: account.id,
|
|
6360
|
+
plexState,
|
|
6361
|
+
request,
|
|
6362
|
+
artistId: id,
|
|
6363
|
+
});
|
|
6364
|
+
if (fallback?.artist) {
|
|
6365
|
+
const fallbackAlbums = fallback.albums || [];
|
|
6366
|
+
const children = fallbackAlbums
|
|
6367
|
+
.map((album) => emptyNode('child', albumAttrs(album, fallback.artist.ratingKey, fallback.artist.title)))
|
|
6368
|
+
.join('');
|
|
6369
|
+
|
|
6370
|
+
return sendSubsonicOk(
|
|
6371
|
+
reply,
|
|
6372
|
+
node(
|
|
6373
|
+
'directory',
|
|
6374
|
+
{
|
|
6375
|
+
id: fallback.artist.ratingKey,
|
|
6376
|
+
parent: rootDirectoryId,
|
|
6377
|
+
name: fallback.artist.title,
|
|
6378
|
+
},
|
|
6379
|
+
children,
|
|
6380
|
+
),
|
|
6381
|
+
);
|
|
6382
|
+
}
|
|
6383
|
+
}
|
|
6384
|
+
|
|
5928
6385
|
const album = await getAlbum({
|
|
5929
6386
|
baseUrl: plexState.baseUrl,
|
|
5930
6387
|
plexToken: plexState.plexToken,
|
|
@@ -6343,10 +6800,17 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6343
6800
|
|
|
6344
6801
|
const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
|
|
6345
6802
|
const rangeHeader = request.headers.range;
|
|
6346
|
-
const upstream = await
|
|
6347
|
-
|
|
6348
|
-
|
|
6803
|
+
const upstream = await fetchWithRetry({
|
|
6804
|
+
url: streamUrl,
|
|
6805
|
+
options: {
|
|
6806
|
+
headers: {
|
|
6807
|
+
...(rangeHeader ? { Range: rangeHeader } : {}),
|
|
6808
|
+
},
|
|
6349
6809
|
},
|
|
6810
|
+
request,
|
|
6811
|
+
context: 'track stream proxy',
|
|
6812
|
+
maxAttempts: 3,
|
|
6813
|
+
baseDelayMs: 250,
|
|
6350
6814
|
});
|
|
6351
6815
|
|
|
6352
6816
|
if (!upstream.ok || !upstream.body) {
|
package/src/subsonic-xml.js
CHANGED
|
@@ -207,6 +207,8 @@ const ARRAY_CHILDREN_BY_PARENT = {
|
|
|
207
207
|
playlists: new Set(['playlist']),
|
|
208
208
|
directory: new Set(['child']),
|
|
209
209
|
playlist: new Set(['entry']),
|
|
210
|
+
playQueue: new Set(['entry']),
|
|
211
|
+
nowPlaying: new Set(['entry']),
|
|
210
212
|
lyricsList: new Set(['structuredLyrics']),
|
|
211
213
|
structuredLyrics: new Set(['line']),
|
|
212
214
|
starred: new Set(['artist', 'album', 'song']),
|