plexsonic 0.1.2 → 0.1.4
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/.env.example +1 -0
- package/README.md +31 -1
- package/package.json +7 -6
- package/src/config.js +1 -0
- package/src/plex.js +54 -0
- package/src/server.js +1178 -229
package/src/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import Fastify from 'fastify';
|
|
|
24
24
|
import argon2 from 'argon2';
|
|
25
25
|
import fastifyCookie from '@fastify/cookie';
|
|
26
26
|
import fastifyFormbody from '@fastify/formbody';
|
|
27
|
+
import fastifyMultipart from '@fastify/multipart';
|
|
27
28
|
import fastifySession from '@fastify/session';
|
|
28
29
|
import { loadConfig } from './config.js';
|
|
29
30
|
import { createRepositories, migrate, openDatabase } from './db.js';
|
|
@@ -61,12 +62,14 @@ import {
|
|
|
61
62
|
listMusicSections,
|
|
62
63
|
listPlexServers,
|
|
63
64
|
pollPlexPin,
|
|
65
|
+
probeSectionFingerprint,
|
|
64
66
|
ratePlexItem,
|
|
65
67
|
removePlexPlaylistItems,
|
|
66
68
|
renamePlexPlaylist,
|
|
67
69
|
searchSectionHubs,
|
|
68
70
|
searchSectionMetadata,
|
|
69
71
|
scrobblePlexItem,
|
|
72
|
+
startPlexSectionScan,
|
|
70
73
|
updatePlexPlaybackStatus,
|
|
71
74
|
} from './plex.js';
|
|
72
75
|
import { createTokenCipher } from './token-crypto.js';
|
|
@@ -89,6 +92,14 @@ const DEFAULT_CORS_ALLOW_HEADERS = [
|
|
|
89
92
|
].join(', ');
|
|
90
93
|
const DEFAULT_CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
|
91
94
|
const DEFAULT_CORS_EXPOSE_HEADERS = 'content-type, content-length, content-range, accept-ranges, etag, last-modified';
|
|
95
|
+
const CACHE_INVALIDATING_PLEX_MEDIA_EVENTS = new Set([
|
|
96
|
+
'media.add',
|
|
97
|
+
'media.delete',
|
|
98
|
+
]);
|
|
99
|
+
const CACHE_PATCHABLE_PLEX_MEDIA_EVENTS = new Set([
|
|
100
|
+
'media.rate',
|
|
101
|
+
'media.unrate',
|
|
102
|
+
]);
|
|
92
103
|
|
|
93
104
|
function applyCorsHeaders(request, reply) {
|
|
94
105
|
const origin = firstForwardedValue(request.headers?.origin);
|
|
@@ -170,6 +181,133 @@ function getBodyFirst(request, key) {
|
|
|
170
181
|
return '';
|
|
171
182
|
}
|
|
172
183
|
|
|
184
|
+
function getBodyFieldValue(body, key) {
|
|
185
|
+
const value = body?.[key];
|
|
186
|
+
if (typeof value === 'string') {
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
if (value && typeof value === 'object' && typeof value.value === 'string') {
|
|
190
|
+
return value.value;
|
|
191
|
+
}
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parsePlexWebhookPayload(body) {
|
|
196
|
+
const parseMaybeJson = (value) => {
|
|
197
|
+
if (!value || typeof value !== 'string') {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(value);
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (!body) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (typeof body === 'string') {
|
|
212
|
+
return parseMaybeJson(body);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (typeof body !== 'object') {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const payloadField = getBodyFieldValue(body, 'payload');
|
|
220
|
+
const parsedPayload = parseMaybeJson(payloadField);
|
|
221
|
+
if (parsedPayload && typeof parsedPayload === 'object') {
|
|
222
|
+
return parsedPayload;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (body.event || body.Metadata || body.Account || body.Server) {
|
|
226
|
+
return body;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isMusicWebhookPayload(payload) {
|
|
233
|
+
const metadata = payload?.Metadata || payload?.metadata || null;
|
|
234
|
+
if (!metadata) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const sectionType = safeLower(metadata.librarySectionType);
|
|
239
|
+
if (sectionType) {
|
|
240
|
+
return sectionType === 'music' || sectionType === 'artist';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const metadataType = safeLower(metadata.type);
|
|
244
|
+
if (!metadataType) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return metadataType === 'track' ||
|
|
249
|
+
metadataType === 'album' ||
|
|
250
|
+
metadataType === 'artist' ||
|
|
251
|
+
metadataType === 'playlist';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function shouldInvalidateCacheForPlexWebhook(payload) {
|
|
255
|
+
if (!payload || !isMusicWebhookPayload(payload)) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const event = safeLower(payload.event);
|
|
260
|
+
if (!event) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (event.startsWith('library.')) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return CACHE_INVALIDATING_PLEX_MEDIA_EVENTS.has(event);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isRatingPatchableWebhookEvent(event) {
|
|
272
|
+
return CACHE_PATCHABLE_PLEX_MEDIA_EVENTS.has(safeLower(event));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function extractRatingPatchFromWebhook(payload) {
|
|
276
|
+
if (!payload) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const event = safeLower(payload.event);
|
|
281
|
+
if (!isRatingPatchableWebhookEvent(event)) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const metadata = payload.Metadata || payload.metadata || {};
|
|
286
|
+
const ratingKey = String(metadata.ratingKey || '').trim();
|
|
287
|
+
if (!ratingKey) {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (event === 'media.unrate') {
|
|
292
|
+
return {
|
|
293
|
+
itemIds: [ratingKey],
|
|
294
|
+
userRating: 0,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const explicitRating = normalizePlexRating(
|
|
299
|
+
metadata.userRating ?? metadata.rating ?? payload.userRating ?? payload.rating,
|
|
300
|
+
);
|
|
301
|
+
if (explicitRating != null) {
|
|
302
|
+
return {
|
|
303
|
+
itemIds: [ratingKey],
|
|
304
|
+
userRating: explicitRating,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
173
311
|
function getRequestParam(request, key) {
|
|
174
312
|
const fromQuery = getQueryFirst(request, key);
|
|
175
313
|
if (fromQuery) {
|
|
@@ -717,12 +855,56 @@ function normalizePlexRating(value) {
|
|
|
717
855
|
return Math.max(0, Math.min(parsed, 10));
|
|
718
856
|
}
|
|
719
857
|
|
|
720
|
-
function
|
|
858
|
+
function normalizePlexRatingInt(value) {
|
|
721
859
|
const normalized = normalizePlexRating(value);
|
|
860
|
+
if (normalized == null) {
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
return Math.round(normalized);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function isPlexLiked(value) {
|
|
867
|
+
const normalized = normalizePlexRatingInt(value);
|
|
868
|
+
return normalized != null && normalized >= 2 && normalized % 2 === 0;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function subsonicRatingToPlexRating(value, { liked = false } = {}) {
|
|
872
|
+
const rating = Number.parseInt(String(value ?? ''), 10);
|
|
873
|
+
if (!Number.isFinite(rating) || rating <= 0) {
|
|
874
|
+
return 0;
|
|
875
|
+
}
|
|
876
|
+
const bounded = Math.max(1, Math.min(5, rating));
|
|
877
|
+
const stars = liked ? Math.max(1, bounded) : bounded;
|
|
878
|
+
return liked ? stars * 2 : (stars * 2) - 1;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function toLikedPlexRating(value) {
|
|
882
|
+
const normalized = normalizePlexRatingInt(value);
|
|
883
|
+
if (normalized == null || normalized <= 0) {
|
|
884
|
+
return 2;
|
|
885
|
+
}
|
|
886
|
+
const stars = Math.max(1, Math.min(5, Math.ceil(normalized / 2)));
|
|
887
|
+
return stars * 2;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function toUnlikedPlexRating(value) {
|
|
891
|
+
const normalized = normalizePlexRatingInt(value);
|
|
892
|
+
if (normalized == null || normalized <= 0) {
|
|
893
|
+
return 0;
|
|
894
|
+
}
|
|
895
|
+
const stars = Math.max(1, Math.min(5, Math.ceil(normalized / 2)));
|
|
896
|
+
return (stars * 2) - 1;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function plexRatingToSubsonic(value) {
|
|
900
|
+
const normalized = normalizePlexRatingInt(value);
|
|
722
901
|
if (normalized == null) {
|
|
723
902
|
return undefined;
|
|
724
903
|
}
|
|
725
|
-
|
|
904
|
+
if (normalized <= 0) {
|
|
905
|
+
return 0;
|
|
906
|
+
}
|
|
907
|
+
return Math.max(1, Math.min(5, Math.ceil(normalized / 2)));
|
|
726
908
|
}
|
|
727
909
|
|
|
728
910
|
function subsonicRatingAttrs(item) {
|
|
@@ -735,7 +917,7 @@ function subsonicRatingAttrs(item) {
|
|
|
735
917
|
userRating: plexRatingToSubsonic(plexRating),
|
|
736
918
|
};
|
|
737
919
|
|
|
738
|
-
if (plexRating
|
|
920
|
+
if (isPlexLiked(plexRating)) {
|
|
739
921
|
attrs.starred = toIsoFromEpochSeconds(item?.updatedAt || item?.addedAt);
|
|
740
922
|
}
|
|
741
923
|
|
|
@@ -752,6 +934,7 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
|
|
|
752
934
|
[album?.parentTitle, fallbackArtistName],
|
|
753
935
|
'Unknown Artist',
|
|
754
936
|
);
|
|
937
|
+
const genre = firstGenreTag(album) || undefined;
|
|
755
938
|
|
|
756
939
|
return {
|
|
757
940
|
id: albumId,
|
|
@@ -767,6 +950,7 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
|
|
|
767
950
|
duration: durationSeconds(album.duration),
|
|
768
951
|
created: toIsoFromEpochSeconds(album.addedAt),
|
|
769
952
|
year: album.year,
|
|
953
|
+
genre,
|
|
770
954
|
...subsonicRatingAttrs(album),
|
|
771
955
|
};
|
|
772
956
|
}
|
|
@@ -870,7 +1054,9 @@ function songAttrs(track, albumTitle = null, albumCoverArt = null, albumMetadata
|
|
|
870
1054
|
[albumCoverArt, track?.parentRatingKey, track?.ratingKey],
|
|
871
1055
|
undefined,
|
|
872
1056
|
);
|
|
873
|
-
const
|
|
1057
|
+
const albumGenreTags = albumMetadata ? allGenreTags(albumMetadata) : [];
|
|
1058
|
+
const genreTagsRaw = allGenreTags(track);
|
|
1059
|
+
const genreTags = genreTagsRaw.length > 0 ? genreTagsRaw : albumGenreTags;
|
|
874
1060
|
const genre = genreTags[0] || undefined;
|
|
875
1061
|
const genres = genreTags.length > 0 ? genreTags.join('; ') : undefined;
|
|
876
1062
|
const discNumber = parsePositiveInt(track?.parentIndex ?? track?.discNumber, 0) || undefined;
|
|
@@ -1508,6 +1694,10 @@ function isAbortError(error) {
|
|
|
1508
1694
|
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
|
|
1509
1695
|
}
|
|
1510
1696
|
|
|
1697
|
+
function isPlexNotFoundError(error) {
|
|
1698
|
+
return String(error?.message || '').includes('(404)');
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1511
1701
|
function safeLower(value) {
|
|
1512
1702
|
return String(value || '').toLowerCase();
|
|
1513
1703
|
}
|
|
@@ -1693,7 +1883,7 @@ function filterAndSortAlbumList(albums, { type, fromYear, toYear, genre }) {
|
|
|
1693
1883
|
});
|
|
1694
1884
|
break;
|
|
1695
1885
|
case 'starred':
|
|
1696
|
-
list = list.filter((album) =>
|
|
1886
|
+
list = list.filter((album) => isPlexLiked(album?.userRating));
|
|
1697
1887
|
list.sort((a, b) => {
|
|
1698
1888
|
const tsA = albumTimestampValue(a, ['updatedAt', 'addedAt']);
|
|
1699
1889
|
const tsB = albumTimestampValue(b, ['updatedAt', 'addedAt']);
|
|
@@ -1775,6 +1965,51 @@ function allGenreTags(item) {
|
|
|
1775
1965
|
return [...new Set(tags)];
|
|
1776
1966
|
}
|
|
1777
1967
|
|
|
1968
|
+
function buildAlbumGenreTagMap(albums) {
|
|
1969
|
+
const map = new Map();
|
|
1970
|
+
for (const album of Array.isArray(albums) ? albums : []) {
|
|
1971
|
+
const albumId = String(album?.ratingKey || '').trim();
|
|
1972
|
+
if (!albumId) {
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
map.set(albumId, allGenreTags(album));
|
|
1976
|
+
}
|
|
1977
|
+
return map;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function resolvedGenreTagsForTrack(track, albumGenreTagMap) {
|
|
1981
|
+
const directTags = allGenreTags(track);
|
|
1982
|
+
if (directTags.length > 0) {
|
|
1983
|
+
return directTags;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
const albumId = String(track?.parentRatingKey || '').trim();
|
|
1987
|
+
if (!albumId) {
|
|
1988
|
+
return [];
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
const albumTags = albumGenreTagMap.get(albumId);
|
|
1992
|
+
return Array.isArray(albumTags) ? albumTags : [];
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
function withResolvedTrackGenres(track, albumGenreTagMap) {
|
|
1996
|
+
const resolvedTags = resolvedGenreTagsForTrack(track, albumGenreTagMap);
|
|
1997
|
+
if (resolvedTags.length === 0) {
|
|
1998
|
+
return track;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const currentTags = allGenreTags(track);
|
|
2002
|
+
if (currentTags.length > 0) {
|
|
2003
|
+
return track;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
return {
|
|
2007
|
+
...track,
|
|
2008
|
+
Genre: resolvedTags.map((tag) => ({ tag })),
|
|
2009
|
+
genre: resolvedTags.join('; '),
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
|
|
1778
2013
|
function firstGenreTag(item) {
|
|
1779
2014
|
const tags = allGenreTags(item);
|
|
1780
2015
|
return tags[0] || null;
|
|
@@ -2023,6 +2258,9 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2023
2258
|
|
|
2024
2259
|
await app.register(fastifyCookie);
|
|
2025
2260
|
await app.register(fastifyFormbody);
|
|
2261
|
+
await app.register(fastifyMultipart, {
|
|
2262
|
+
attachFieldsToBody: true,
|
|
2263
|
+
});
|
|
2026
2264
|
const sessionStore = createSqliteSessionStore(db, app.log);
|
|
2027
2265
|
await app.register(fastifySession, {
|
|
2028
2266
|
secret: config.sessionSecret,
|
|
@@ -2056,12 +2294,16 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2056
2294
|
const STREAM_PROGRESS_HEARTBEAT_MS = 10000;
|
|
2057
2295
|
const activeSearchRequests = new Map();
|
|
2058
2296
|
const searchBrowseCache = new Map();
|
|
2059
|
-
const
|
|
2297
|
+
const SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS = 15000;
|
|
2298
|
+
const SEARCH_BROWSE_CHANGE_CHECK_DEBOUNCE_MS = 15000;
|
|
2299
|
+
const SEARCH_BROWSE_CACHE_IDLE_TTL_MS = 10 * 60 * 1000;
|
|
2300
|
+
const SEARCH_BROWSE_RATING_OVERRIDE_TTL_MS = 2 * 60 * 1000;
|
|
2060
2301
|
const SEARCH_BROWSE_CACHE_MAX_ENTRIES = 16;
|
|
2302
|
+
const SEARCH_BROWSE_COLLECTIONS = ['artists', 'albums', 'tracks'];
|
|
2061
2303
|
|
|
2062
2304
|
function pruneSearchBrowseCache(now = Date.now()) {
|
|
2063
2305
|
for (const [cacheKey, entry] of searchBrowseCache.entries()) {
|
|
2064
|
-
if (!entry || entry.
|
|
2306
|
+
if (!entry || (now - Number(entry.lastAccessAt || 0)) > SEARCH_BROWSE_CACHE_IDLE_TTL_MS) {
|
|
2065
2307
|
searchBrowseCache.delete(cacheKey);
|
|
2066
2308
|
}
|
|
2067
2309
|
}
|
|
@@ -2071,72 +2313,650 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2071
2313
|
}
|
|
2072
2314
|
|
|
2073
2315
|
const sortedByExpiry = [...searchBrowseCache.entries()]
|
|
2074
|
-
.sort((a, b) => (a[1]?.
|
|
2316
|
+
.sort((a, b) => (a[1]?.lastAccessAt || 0) - (b[1]?.lastAccessAt || 0));
|
|
2075
2317
|
for (const [cacheKey] of sortedByExpiry) {
|
|
2076
2318
|
if (searchBrowseCache.size <= SEARCH_BROWSE_CACHE_MAX_ENTRIES) {
|
|
2077
2319
|
break;
|
|
2078
2320
|
}
|
|
2079
2321
|
searchBrowseCache.delete(cacheKey);
|
|
2080
2322
|
}
|
|
2081
|
-
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
function searchBrowseCacheKey(accountId, plexState) {
|
|
2326
|
+
return `${accountId}:${plexState.machineId}:${plexState.musicSectionId}`;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function createSearchBrowseCollectionState() {
|
|
2330
|
+
return {
|
|
2331
|
+
data: null,
|
|
2332
|
+
loading: null,
|
|
2333
|
+
loadedAt: 0,
|
|
2334
|
+
lastRefreshAt: 0,
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
function getSearchBrowseCacheEntry(cacheKey, now = Date.now()) {
|
|
2339
|
+
const existing = searchBrowseCache.get(cacheKey);
|
|
2340
|
+
if (existing) {
|
|
2341
|
+
existing.lastAccessAt = now;
|
|
2342
|
+
return existing;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
const created = {
|
|
2346
|
+
lastAccessAt: now,
|
|
2347
|
+
collections: {
|
|
2348
|
+
artists: createSearchBrowseCollectionState(),
|
|
2349
|
+
albums: createSearchBrowseCollectionState(),
|
|
2350
|
+
tracks: createSearchBrowseCollectionState(),
|
|
2351
|
+
},
|
|
2352
|
+
libraryState: {
|
|
2353
|
+
lastCheckedAt: 0,
|
|
2354
|
+
checking: null,
|
|
2355
|
+
lastFingerprint: '',
|
|
2356
|
+
dirty: false,
|
|
2357
|
+
refreshPromise: null,
|
|
2358
|
+
ratingOverrides: new Map(),
|
|
2359
|
+
},
|
|
2360
|
+
};
|
|
2361
|
+
searchBrowseCache.set(cacheKey, created);
|
|
2362
|
+
pruneSearchBrowseCache(now);
|
|
2363
|
+
return created;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function getSearchBrowseCollectionState(entry, collection) {
|
|
2367
|
+
if (!entry?.collections || !SEARCH_BROWSE_COLLECTIONS.includes(collection)) {
|
|
2368
|
+
throw new Error(`Invalid cache collection: ${collection}`);
|
|
2369
|
+
}
|
|
2370
|
+
return entry.collections[collection];
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
async function loadSearchBrowseCollection({ entry, collection, loader, request = null, background = false }) {
|
|
2374
|
+
const state = getSearchBrowseCollectionState(entry, collection);
|
|
2375
|
+
if (state.loading) {
|
|
2376
|
+
return state.loading;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
state.lastRefreshAt = Date.now();
|
|
2380
|
+
|
|
2381
|
+
const pending = (async () => {
|
|
2382
|
+
const loaded = await loader();
|
|
2383
|
+
return Array.isArray(loaded) ? loaded : [];
|
|
2384
|
+
})();
|
|
2385
|
+
state.loading = pending;
|
|
2386
|
+
|
|
2387
|
+
try {
|
|
2388
|
+
const loaded = await pending;
|
|
2389
|
+
applyRatingOverridesToItems(entry, loaded, Date.now());
|
|
2390
|
+
state.data = loaded;
|
|
2391
|
+
state.loadedAt = Date.now();
|
|
2392
|
+
return loaded;
|
|
2393
|
+
} catch (error) {
|
|
2394
|
+
if (background) {
|
|
2395
|
+
request?.log?.debug(error, `Background refresh failed for ${collection} cache`);
|
|
2396
|
+
return Array.isArray(state.data) ? state.data : [];
|
|
2397
|
+
}
|
|
2398
|
+
throw error;
|
|
2399
|
+
} finally {
|
|
2400
|
+
if (state.loading === pending) {
|
|
2401
|
+
state.loading = null;
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
async function loadLibraryArtistsRaw({ plexState }) {
|
|
2407
|
+
const loaded = await listArtists({
|
|
2408
|
+
baseUrl: plexState.baseUrl,
|
|
2409
|
+
plexToken: plexState.plexToken,
|
|
2410
|
+
sectionId: plexState.musicSectionId,
|
|
2411
|
+
});
|
|
2412
|
+
return [...loaded].sort((a, b) => String(a?.title || '').localeCompare(String(b?.title || '')));
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
async function loadLibraryAlbumsRaw({ plexState }) {
|
|
2416
|
+
const loaded = await listAlbums({
|
|
2417
|
+
baseUrl: plexState.baseUrl,
|
|
2418
|
+
plexToken: plexState.plexToken,
|
|
2419
|
+
sectionId: plexState.musicSectionId,
|
|
2420
|
+
});
|
|
2421
|
+
return sortAlbumsByName(loaded);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
async function loadLibraryTracksRaw({ plexState }) {
|
|
2425
|
+
const [tracks, albums] = await Promise.all([
|
|
2426
|
+
listTracks({
|
|
2427
|
+
baseUrl: plexState.baseUrl,
|
|
2428
|
+
plexToken: plexState.plexToken,
|
|
2429
|
+
sectionId: plexState.musicSectionId,
|
|
2430
|
+
}),
|
|
2431
|
+
listAlbums({
|
|
2432
|
+
baseUrl: plexState.baseUrl,
|
|
2433
|
+
plexToken: plexState.plexToken,
|
|
2434
|
+
sectionId: plexState.musicSectionId,
|
|
2435
|
+
}),
|
|
2436
|
+
]);
|
|
2437
|
+
const albumGenreTagMap = buildAlbumGenreTagMap(albums);
|
|
2438
|
+
const enrichedTracks = tracks.map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
|
|
2439
|
+
return sortTracksForLibraryBrowse(enrichedTracks);
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function getLibraryCollectionLoader(collection, plexState) {
|
|
2443
|
+
if (collection === 'artists') {
|
|
2444
|
+
return () => loadLibraryArtistsRaw({ plexState });
|
|
2445
|
+
}
|
|
2446
|
+
if (collection === 'albums') {
|
|
2447
|
+
return () => loadLibraryAlbumsRaw({ plexState });
|
|
2448
|
+
}
|
|
2449
|
+
if (collection === 'tracks') {
|
|
2450
|
+
return () => loadLibraryTracksRaw({ plexState });
|
|
2451
|
+
}
|
|
2452
|
+
throw new Error(`Unsupported library collection: ${collection}`);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
function markSearchBrowseCacheDirty(cacheKey = null) {
|
|
2456
|
+
const targets = cacheKey
|
|
2457
|
+
? [[cacheKey, searchBrowseCache.get(cacheKey)]]
|
|
2458
|
+
: [...searchBrowseCache.entries()];
|
|
2459
|
+
|
|
2460
|
+
for (const [, entry] of targets) {
|
|
2461
|
+
if (!entry?.libraryState) {
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
entry.libraryState.dirty = true;
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
function normalizeRatingOverride({ userRating = null, clearUserRating = false }, nowMs) {
|
|
2469
|
+
if (clearUserRating) {
|
|
2470
|
+
return {
|
|
2471
|
+
userRating: 0,
|
|
2472
|
+
updatedAtSeconds: Math.floor(nowMs / 1000),
|
|
2473
|
+
expiresAt: nowMs + SEARCH_BROWSE_RATING_OVERRIDE_TTL_MS,
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
const normalized = normalizePlexRating(userRating);
|
|
2478
|
+
if (normalized == null) {
|
|
2479
|
+
return null;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
return {
|
|
2483
|
+
userRating: normalized,
|
|
2484
|
+
updatedAtSeconds: Math.floor(nowMs / 1000),
|
|
2485
|
+
expiresAt: nowMs + SEARCH_BROWSE_RATING_OVERRIDE_TTL_MS,
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
function pruneExpiredRatingOverrides(entry, nowMs) {
|
|
2490
|
+
const overrides = entry?.libraryState?.ratingOverrides;
|
|
2491
|
+
if (!(overrides instanceof Map)) {
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
for (const [itemId, override] of overrides.entries()) {
|
|
2496
|
+
if (!override || Number(override.expiresAt || 0) <= nowMs) {
|
|
2497
|
+
overrides.delete(itemId);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
function recordRatingOverrides(entry, itemIds, ratingPatch, nowMs) {
|
|
2503
|
+
const overrides = entry?.libraryState?.ratingOverrides;
|
|
2504
|
+
if (!(overrides instanceof Map)) {
|
|
2505
|
+
return false;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
const normalized = normalizeRatingOverride(ratingPatch, nowMs);
|
|
2509
|
+
if (!normalized) {
|
|
2510
|
+
return false;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
let wrote = false;
|
|
2514
|
+
for (const id of itemIds) {
|
|
2515
|
+
const key = String(id || '').trim();
|
|
2516
|
+
if (!key) {
|
|
2517
|
+
continue;
|
|
2518
|
+
}
|
|
2519
|
+
overrides.set(key, normalized);
|
|
2520
|
+
wrote = true;
|
|
2521
|
+
}
|
|
2522
|
+
return wrote;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function applyRatingOverridesToItems(entry, items, nowMs = Date.now()) {
|
|
2526
|
+
const overrides = entry?.libraryState?.ratingOverrides;
|
|
2527
|
+
if (!(overrides instanceof Map) || !Array.isArray(items) || items.length === 0) {
|
|
2528
|
+
return 0;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
pruneExpiredRatingOverrides(entry, nowMs);
|
|
2532
|
+
|
|
2533
|
+
let patched = 0;
|
|
2534
|
+
for (const item of items) {
|
|
2535
|
+
const ratingKey = String(item?.ratingKey || '').trim();
|
|
2536
|
+
if (!ratingKey) {
|
|
2537
|
+
continue;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
const override = overrides.get(ratingKey);
|
|
2541
|
+
if (!override) {
|
|
2542
|
+
continue;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
item.userRating = override.userRating;
|
|
2546
|
+
if (Number.isFinite(override.updatedAtSeconds) && override.updatedAtSeconds > 0) {
|
|
2547
|
+
item.updatedAt = override.updatedAtSeconds;
|
|
2548
|
+
}
|
|
2549
|
+
if (!isPlexLiked(item.userRating)) {
|
|
2550
|
+
delete item.starred;
|
|
2551
|
+
delete item.starredAt;
|
|
2552
|
+
}
|
|
2553
|
+
patched += 1;
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
return patched;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function applyUserRatingPatchToCacheEntry(entry, itemIds, { userRating = null, clearUserRating = false }, updatedAtSeconds) {
|
|
2560
|
+
if (!entry?.collections) {
|
|
2561
|
+
return 0;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
const ids = new Set(
|
|
2565
|
+
(Array.isArray(itemIds) ? itemIds : [])
|
|
2566
|
+
.map((id) => String(id || '').trim())
|
|
2567
|
+
.filter(Boolean),
|
|
2568
|
+
);
|
|
2569
|
+
if (ids.size === 0) {
|
|
2570
|
+
return 0;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
const nowMs = Date.now();
|
|
2574
|
+
recordRatingOverrides(entry, [...ids], { userRating, clearUserRating }, nowMs);
|
|
2575
|
+
pruneExpiredRatingOverrides(entry, nowMs);
|
|
2576
|
+
|
|
2577
|
+
let patchedCount = 0;
|
|
2578
|
+
for (const collection of SEARCH_BROWSE_COLLECTIONS) {
|
|
2579
|
+
const state = entry.collections?.[collection];
|
|
2580
|
+
if (!Array.isArray(state?.data)) {
|
|
2581
|
+
continue;
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
for (const item of state.data) {
|
|
2585
|
+
const ratingKey = String(item?.ratingKey || '').trim();
|
|
2586
|
+
if (!ratingKey || !ids.has(ratingKey)) {
|
|
2587
|
+
continue;
|
|
2588
|
+
}
|
|
2589
|
+
if (clearUserRating) {
|
|
2590
|
+
item.userRating = 0;
|
|
2591
|
+
} else {
|
|
2592
|
+
item.userRating = userRating;
|
|
2593
|
+
}
|
|
2594
|
+
if (!isPlexLiked(item.userRating)) {
|
|
2595
|
+
delete item.starred;
|
|
2596
|
+
delete item.starredAt;
|
|
2597
|
+
}
|
|
2598
|
+
if (Number.isFinite(updatedAtSeconds) && updatedAtSeconds > 0) {
|
|
2599
|
+
item.updatedAt = updatedAtSeconds;
|
|
2600
|
+
}
|
|
2601
|
+
patchedCount += 1;
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
return patchedCount;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
function applyUserRatingPatchToSearchBrowseCache({ cacheKey = null, itemIds, userRating = null, clearUserRating = false }) {
|
|
2609
|
+
if (!clearUserRating && normalizePlexRating(userRating) == null) {
|
|
2610
|
+
return 0;
|
|
2611
|
+
}
|
|
2612
|
+
const normalizedRating = clearUserRating ? null : normalizePlexRating(userRating);
|
|
2613
|
+
|
|
2614
|
+
const updatedAtSeconds = Math.floor(Date.now() / 1000);
|
|
2615
|
+
let targets;
|
|
2616
|
+
if (cacheKey) {
|
|
2617
|
+
const ensuredEntry = getSearchBrowseCacheEntry(cacheKey, Date.now());
|
|
2618
|
+
targets = [[cacheKey, ensuredEntry]];
|
|
2619
|
+
} else {
|
|
2620
|
+
targets = [...searchBrowseCache.entries()];
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
let patchedCount = 0;
|
|
2624
|
+
for (const [, entry] of targets) {
|
|
2625
|
+
patchedCount += applyUserRatingPatchToCacheEntry(
|
|
2626
|
+
entry,
|
|
2627
|
+
itemIds,
|
|
2628
|
+
{
|
|
2629
|
+
userRating: normalizedRating,
|
|
2630
|
+
clearUserRating,
|
|
2631
|
+
},
|
|
2632
|
+
updatedAtSeconds,
|
|
2633
|
+
);
|
|
2634
|
+
}
|
|
2635
|
+
return patchedCount;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
function getCachedUserRatingForItem(entry, itemId) {
|
|
2639
|
+
const key = String(itemId || '').trim();
|
|
2640
|
+
if (!key || !entry) {
|
|
2641
|
+
return null;
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
const nowMs = Date.now();
|
|
2645
|
+
pruneExpiredRatingOverrides(entry, nowMs);
|
|
2646
|
+
const override = entry?.libraryState?.ratingOverrides?.get(key);
|
|
2647
|
+
if (override && Number.isFinite(override.userRating)) {
|
|
2648
|
+
return normalizePlexRatingInt(override.userRating);
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
for (const collection of SEARCH_BROWSE_COLLECTIONS) {
|
|
2652
|
+
const state = entry.collections?.[collection];
|
|
2653
|
+
if (!Array.isArray(state?.data)) {
|
|
2654
|
+
continue;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
const found = state.data.find((item) => String(item?.ratingKey || '').trim() === key);
|
|
2658
|
+
if (!found) {
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
return normalizePlexRatingInt(found.userRating);
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
return null;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
async function getLibraryFingerprint({ plexState }) {
|
|
2668
|
+
const sectionId = String(plexState.musicSectionId || '');
|
|
2669
|
+
const [sections, probe] = await Promise.all([
|
|
2670
|
+
listMusicSections({
|
|
2671
|
+
baseUrl: plexState.baseUrl,
|
|
2672
|
+
plexToken: plexState.plexToken,
|
|
2673
|
+
}),
|
|
2674
|
+
probeSectionFingerprint({
|
|
2675
|
+
baseUrl: plexState.baseUrl,
|
|
2676
|
+
plexToken: plexState.plexToken,
|
|
2677
|
+
sectionId,
|
|
2678
|
+
}).catch(() => ''),
|
|
2679
|
+
]);
|
|
2680
|
+
|
|
2681
|
+
const section = sections.find((item) => String(item?.id || '') === sectionId);
|
|
2682
|
+
const sectionPart = section
|
|
2683
|
+
? `${section.id}|${section.updatedAt}|${section.scannedAt}|${section.refreshedAt}|${section.contentChangedAt}|${section.leafCount}`
|
|
2684
|
+
: `${sectionId}|missing`;
|
|
2685
|
+
return `${sectionPart}|${probe || ''}`;
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
async function refreshSearchBrowseCollectionsForEntry({ entry, plexState, request }) {
|
|
2689
|
+
await Promise.all(
|
|
2690
|
+
SEARCH_BROWSE_COLLECTIONS.map((collection) =>
|
|
2691
|
+
loadSearchBrowseCollection({
|
|
2692
|
+
entry,
|
|
2693
|
+
collection,
|
|
2694
|
+
loader: getLibraryCollectionLoader(collection, plexState),
|
|
2695
|
+
request,
|
|
2696
|
+
background: false,
|
|
2697
|
+
}),
|
|
2698
|
+
),
|
|
2699
|
+
);
|
|
2700
|
+
entry.libraryState.dirty = false;
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
async function ensureDirtySearchBrowseRefresh({
|
|
2704
|
+
entry,
|
|
2705
|
+
cacheKey,
|
|
2706
|
+
plexState,
|
|
2707
|
+
request,
|
|
2708
|
+
wait = false,
|
|
2709
|
+
}) {
|
|
2710
|
+
if (!entry?.libraryState?.dirty) {
|
|
2711
|
+
return;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
const libraryState = entry.libraryState;
|
|
2715
|
+
if (!libraryState.refreshPromise) {
|
|
2716
|
+
const pending = (async () => {
|
|
2717
|
+
try {
|
|
2718
|
+
await refreshSearchBrowseCollectionsForEntry({ entry, plexState, request });
|
|
2719
|
+
} catch (error) {
|
|
2720
|
+
request?.log?.warn(error, `Failed to refresh stale cache for ${cacheKey}`);
|
|
2721
|
+
throw error;
|
|
2722
|
+
} finally {
|
|
2723
|
+
if (libraryState.refreshPromise === pending) {
|
|
2724
|
+
libraryState.refreshPromise = null;
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
})();
|
|
2728
|
+
libraryState.refreshPromise = pending;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
if (wait && libraryState.refreshPromise) {
|
|
2732
|
+
try {
|
|
2733
|
+
await libraryState.refreshPromise;
|
|
2734
|
+
} catch {
|
|
2735
|
+
// Keep serving stale cache on refresh failures.
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
function maybeCheckLibraryChanges({ entry, cacheKey, plexState, request }) {
|
|
2741
|
+
if (!request || !entry?.libraryState) {
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
const libraryState = entry.libraryState;
|
|
2746
|
+
const now = Date.now();
|
|
2747
|
+
if (libraryState.checking) {
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
if ((now - Number(libraryState.lastCheckedAt || 0)) < SEARCH_BROWSE_CHANGE_CHECK_DEBOUNCE_MS) {
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
libraryState.lastCheckedAt = now;
|
|
2755
|
+
const pending = (async () => {
|
|
2756
|
+
try {
|
|
2757
|
+
const fingerprint = await getLibraryFingerprint({ plexState });
|
|
2758
|
+
if (!fingerprint) {
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
if (!libraryState.lastFingerprint) {
|
|
2763
|
+
libraryState.lastFingerprint = fingerprint;
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
if (libraryState.lastFingerprint !== fingerprint) {
|
|
2768
|
+
libraryState.lastFingerprint = fingerprint;
|
|
2769
|
+
libraryState.dirty = true;
|
|
2770
|
+
request.log.info({ cacheKey }, 'Plex library change detected, refreshing cache');
|
|
2771
|
+
await ensureDirtySearchBrowseRefresh({
|
|
2772
|
+
entry,
|
|
2773
|
+
cacheKey,
|
|
2774
|
+
plexState,
|
|
2775
|
+
request,
|
|
2776
|
+
wait: false,
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
request.log.debug(error, `Failed to check library changes for ${cacheKey}`);
|
|
2781
|
+
} finally {
|
|
2782
|
+
if (libraryState.checking === pending) {
|
|
2783
|
+
libraryState.checking = null;
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
})();
|
|
2787
|
+
libraryState.checking = pending;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
function hasCachedLibraryData(entry) {
|
|
2791
|
+
if (!entry?.collections) {
|
|
2792
|
+
return false;
|
|
2793
|
+
}
|
|
2794
|
+
return SEARCH_BROWSE_COLLECTIONS.some((collection) => Array.isArray(entry.collections?.[collection]?.data));
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
function shouldRunLibraryCheckForRequest(request, cacheKey) {
|
|
2798
|
+
if (!request) {
|
|
2799
|
+
return true;
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
const markerKey = '__plexsonicLibraryChecks';
|
|
2803
|
+
let checkedCacheKeys = request[markerKey];
|
|
2804
|
+
if (!(checkedCacheKeys instanceof Set)) {
|
|
2805
|
+
checkedCacheKeys = new Set();
|
|
2806
|
+
request[markerKey] = checkedCacheKeys;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
if (checkedCacheKeys.has(cacheKey)) {
|
|
2810
|
+
return false;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
checkedCacheKeys.add(cacheKey);
|
|
2814
|
+
return true;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
async function getSearchBrowseCollection({ cacheKey, collection, loader, plexState, request = null }) {
|
|
2818
|
+
const now = Date.now();
|
|
2819
|
+
const entry = getSearchBrowseCacheEntry(cacheKey, now);
|
|
2820
|
+
const state = getSearchBrowseCollectionState(entry, collection);
|
|
2821
|
+
|
|
2822
|
+
if (hasCachedLibraryData(entry) && shouldRunLibraryCheckForRequest(request, cacheKey)) {
|
|
2823
|
+
maybeCheckLibraryChanges({ entry, cacheKey, plexState, request });
|
|
2824
|
+
}
|
|
2825
|
+
await ensureDirtySearchBrowseRefresh({
|
|
2826
|
+
entry,
|
|
2827
|
+
cacheKey,
|
|
2828
|
+
plexState,
|
|
2829
|
+
request,
|
|
2830
|
+
wait: true,
|
|
2831
|
+
});
|
|
2832
|
+
|
|
2833
|
+
if (Array.isArray(state.data)) {
|
|
2834
|
+
const isStale = (now - Number(state.loadedAt || 0)) >= SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS;
|
|
2835
|
+
const canRefresh = (now - Number(state.lastRefreshAt || 0)) >= SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS;
|
|
2836
|
+
if (isStale && canRefresh && !state.loading) {
|
|
2837
|
+
void loadSearchBrowseCollection({
|
|
2838
|
+
entry,
|
|
2839
|
+
collection,
|
|
2840
|
+
loader,
|
|
2841
|
+
request,
|
|
2842
|
+
background: true,
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
return state.data;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
if (state.loading) {
|
|
2849
|
+
return state.loading;
|
|
2850
|
+
}
|
|
2082
2851
|
|
|
2083
|
-
|
|
2084
|
-
|
|
2852
|
+
return loadSearchBrowseCollection({
|
|
2853
|
+
entry,
|
|
2854
|
+
collection,
|
|
2855
|
+
loader,
|
|
2856
|
+
request,
|
|
2857
|
+
background: false,
|
|
2858
|
+
});
|
|
2085
2859
|
}
|
|
2086
2860
|
|
|
2087
|
-
function
|
|
2088
|
-
const
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2861
|
+
async function getCachedLibraryArtists({ accountId, plexState, request }) {
|
|
2862
|
+
const cacheKey = searchBrowseCacheKey(accountId, plexState);
|
|
2863
|
+
return getSearchBrowseCollection({
|
|
2864
|
+
cacheKey,
|
|
2865
|
+
collection: 'artists',
|
|
2866
|
+
plexState,
|
|
2867
|
+
request,
|
|
2868
|
+
loader: () => loadLibraryArtistsRaw({ plexState }),
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2094
2871
|
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2872
|
+
async function getCachedLibraryAlbums({ accountId, plexState, request }) {
|
|
2873
|
+
const cacheKey = searchBrowseCacheKey(accountId, plexState);
|
|
2874
|
+
return getSearchBrowseCollection({
|
|
2875
|
+
cacheKey,
|
|
2876
|
+
collection: 'albums',
|
|
2877
|
+
plexState,
|
|
2878
|
+
request,
|
|
2879
|
+
loader: () => loadLibraryAlbumsRaw({ plexState }),
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2098
2882
|
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
};
|
|
2108
|
-
searchBrowseCache.set(cacheKey, created);
|
|
2109
|
-
pruneSearchBrowseCache(now);
|
|
2110
|
-
return created;
|
|
2883
|
+
async function getCachedLibraryTracks({ accountId, plexState, request }) {
|
|
2884
|
+
const cacheKey = searchBrowseCacheKey(accountId, plexState);
|
|
2885
|
+
return getSearchBrowseCollection({
|
|
2886
|
+
cacheKey,
|
|
2887
|
+
collection: 'tracks',
|
|
2888
|
+
plexState,
|
|
2889
|
+
request,
|
|
2890
|
+
loader: () => loadLibraryTracksRaw({ plexState }),
|
|
2891
|
+
});
|
|
2111
2892
|
}
|
|
2112
2893
|
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
return entry[collection];
|
|
2894
|
+
function applyCachedRatingOverridesForAccount({ accountId, plexState, items }) {
|
|
2895
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
2896
|
+
return 0;
|
|
2117
2897
|
}
|
|
2898
|
+
const cacheKey = searchBrowseCacheKey(accountId, plexState);
|
|
2899
|
+
const entry = searchBrowseCache.get(cacheKey);
|
|
2900
|
+
if (!entry) {
|
|
2901
|
+
return 0;
|
|
2902
|
+
}
|
|
2903
|
+
return applyRatingOverridesToItems(entry, items, Date.now());
|
|
2904
|
+
}
|
|
2118
2905
|
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2906
|
+
async function resolveArtistFromCachedLibrary({ accountId, plexState, request, artistId }) {
|
|
2907
|
+
const normalizedArtistId = String(artistId || '').trim();
|
|
2908
|
+
if (!normalizedArtistId) {
|
|
2909
|
+
return null;
|
|
2122
2910
|
}
|
|
2123
2911
|
|
|
2124
|
-
const
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2912
|
+
const [artists, albums, tracks] = await Promise.all([
|
|
2913
|
+
getCachedLibraryArtists({ accountId, plexState, request }),
|
|
2914
|
+
getCachedLibraryAlbums({ accountId, plexState, request }),
|
|
2915
|
+
getCachedLibraryTracks({ accountId, plexState, request }),
|
|
2916
|
+
]);
|
|
2129
2917
|
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
if (entry[loadingKey] === pending) {
|
|
2137
|
-
entry[loadingKey] = null;
|
|
2138
|
-
}
|
|
2918
|
+
const cachedArtist = artists.find((item) => String(item?.ratingKey || '').trim() === normalizedArtistId) || null;
|
|
2919
|
+
const cachedAlbums = albums.filter((item) => String(item?.parentRatingKey || '').trim() === normalizedArtistId);
|
|
2920
|
+
const artistTracks = tracks.filter((item) => String(item?.grandparentRatingKey || '').trim() === normalizedArtistId);
|
|
2921
|
+
|
|
2922
|
+
if (!cachedArtist && cachedAlbums.length === 0 && artistTracks.length === 0) {
|
|
2923
|
+
return null;
|
|
2139
2924
|
}
|
|
2925
|
+
|
|
2926
|
+
const artistName = cachedArtist?.title ||
|
|
2927
|
+
firstNonEmptyText(artistTracks.map((item) => item?.grandparentTitle), `Artist ${normalizedArtistId}`);
|
|
2928
|
+
|
|
2929
|
+
const artist = cachedArtist || {
|
|
2930
|
+
ratingKey: normalizedArtistId,
|
|
2931
|
+
title: artistName,
|
|
2932
|
+
addedAt: artistTracks[0]?.addedAt,
|
|
2933
|
+
updatedAt: artistTracks[0]?.updatedAt,
|
|
2934
|
+
};
|
|
2935
|
+
|
|
2936
|
+
const resolvedAlbums = cachedAlbums.length > 0
|
|
2937
|
+
? cachedAlbums
|
|
2938
|
+
: deriveAlbumsFromTracks(artistTracks, normalizedArtistId, artistName);
|
|
2939
|
+
|
|
2940
|
+
return {
|
|
2941
|
+
artist,
|
|
2942
|
+
albums: resolvedAlbums,
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
function scanStatusAttrsFromSection(section, { fallbackScanning = false } = {}) {
|
|
2947
|
+
return {
|
|
2948
|
+
scanning: Boolean(section?.scanning ?? fallbackScanning),
|
|
2949
|
+
count: parseNonNegativeInt(section?.leafCount, 0),
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
async function getMusicSectionScanStatus(plexState) {
|
|
2954
|
+
const sections = await listMusicSections({
|
|
2955
|
+
baseUrl: plexState.baseUrl,
|
|
2956
|
+
plexToken: plexState.plexToken,
|
|
2957
|
+
});
|
|
2958
|
+
const section = sections.find((item) => String(item?.id || '') === String(plexState.musicSectionId || ''));
|
|
2959
|
+
return section || null;
|
|
2140
2960
|
}
|
|
2141
2961
|
|
|
2142
2962
|
function beginSearchRequest(request, accountId) {
|
|
@@ -2329,6 +3149,53 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2329
3149
|
|
|
2330
3150
|
app.get('/health', async () => ({ status: 'ok' }));
|
|
2331
3151
|
|
|
3152
|
+
app.post('/webhooks/plex', async (request, reply) => {
|
|
3153
|
+
const expectedToken = String(config.plexWebhookToken || '').trim();
|
|
3154
|
+
const providedToken = String(
|
|
3155
|
+
firstForwardedValue(request.headers?.['x-plexsonic-webhook-token']) ||
|
|
3156
|
+
getQueryFirst(request, 'token') ||
|
|
3157
|
+
getBodyFieldValue(request.body, 'token') ||
|
|
3158
|
+
'',
|
|
3159
|
+
).trim();
|
|
3160
|
+
|
|
3161
|
+
if (expectedToken && providedToken !== expectedToken) {
|
|
3162
|
+
request.log.warn({ ip: request.ip }, 'Rejected Plex webhook with invalid token');
|
|
3163
|
+
return reply.code(403).send({ ok: false });
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
const payload = parsePlexWebhookPayload(request.body);
|
|
3167
|
+
const event = String(payload?.event || '').trim() || 'unknown';
|
|
3168
|
+
if (isRatingPatchableWebhookEvent(event)) {
|
|
3169
|
+
const ratingPatch = extractRatingPatchFromWebhook(payload);
|
|
3170
|
+
if (ratingPatch) {
|
|
3171
|
+
const patchedCount = applyUserRatingPatchToSearchBrowseCache({
|
|
3172
|
+
itemIds: ratingPatch.itemIds,
|
|
3173
|
+
userRating: ratingPatch.userRating,
|
|
3174
|
+
clearUserRating: ratingPatch.clearUserRating,
|
|
3175
|
+
});
|
|
3176
|
+
if (patchedCount === 0) {
|
|
3177
|
+
markSearchBrowseCacheDirty();
|
|
3178
|
+
}
|
|
3179
|
+
request.log.info({ event, patchedCount }, 'Plex webhook rating event applied to cache');
|
|
3180
|
+
return reply.code(202).send({ ok: true, patched: patchedCount });
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
markSearchBrowseCacheDirty();
|
|
3184
|
+
request.log.info({ event }, 'Plex webhook rating event missing metadata, marked caches dirty');
|
|
3185
|
+
return reply.code(202).send({ ok: true });
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
if (!shouldInvalidateCacheForPlexWebhook(payload)) {
|
|
3189
|
+
request.log.debug({ event }, 'Plex webhook ignored (non-library-changing event)');
|
|
3190
|
+
return reply.code(202).send({ ok: true, ignored: true });
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
markSearchBrowseCacheDirty();
|
|
3194
|
+
request.log.info({ event }, 'Plex webhook received, marked caches dirty');
|
|
3195
|
+
|
|
3196
|
+
return reply.code(202).send({ ok: true });
|
|
3197
|
+
});
|
|
3198
|
+
|
|
2332
3199
|
app.get('/', async (request, reply) => {
|
|
2333
3200
|
if (request.session.accountId) {
|
|
2334
3201
|
return reply.redirect('/link/plex');
|
|
@@ -3073,13 +3940,22 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3073
3940
|
return;
|
|
3074
3941
|
}
|
|
3075
3942
|
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3943
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
3944
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
3945
|
+
if (!plexState) {
|
|
3946
|
+
return;
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
try {
|
|
3950
|
+
const section = await getMusicSectionScanStatus(plexState);
|
|
3951
|
+
return sendSubsonicOk(
|
|
3952
|
+
reply,
|
|
3953
|
+
emptyNode('scanStatus', scanStatusAttrsFromSection(section)),
|
|
3954
|
+
);
|
|
3955
|
+
} catch (error) {
|
|
3956
|
+
request.log.error(error, 'Failed to load scan status from Plex');
|
|
3957
|
+
return sendSubsonicError(reply, 10, 'Failed to load scan status');
|
|
3958
|
+
}
|
|
3083
3959
|
});
|
|
3084
3960
|
|
|
3085
3961
|
app.get('/rest/startScan.view', async (request, reply) => {
|
|
@@ -3088,13 +3964,31 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3088
3964
|
return;
|
|
3089
3965
|
}
|
|
3090
3966
|
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3967
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
3968
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
3969
|
+
if (!plexState) {
|
|
3970
|
+
return;
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
try {
|
|
3974
|
+
await startPlexSectionScan({
|
|
3975
|
+
baseUrl: plexState.baseUrl,
|
|
3976
|
+
plexToken: plexState.plexToken,
|
|
3977
|
+
sectionId: plexState.musicSectionId,
|
|
3978
|
+
force: true,
|
|
3979
|
+
});
|
|
3980
|
+
|
|
3981
|
+
markSearchBrowseCacheDirty(searchBrowseCacheKey(account.id, plexState));
|
|
3982
|
+
|
|
3983
|
+
const section = await getMusicSectionScanStatus(plexState);
|
|
3984
|
+
return sendSubsonicOk(
|
|
3985
|
+
reply,
|
|
3986
|
+
emptyNode('scanStatus', scanStatusAttrsFromSection(section, { fallbackScanning: true })),
|
|
3987
|
+
);
|
|
3988
|
+
} catch (error) {
|
|
3989
|
+
request.log.error(error, 'Failed to trigger Plex scan');
|
|
3990
|
+
return sendSubsonicError(reply, 10, 'Failed to trigger scan');
|
|
3991
|
+
}
|
|
3098
3992
|
});
|
|
3099
3993
|
|
|
3100
3994
|
app.get('/rest/getStarred.view', async (request, reply) => {
|
|
@@ -3111,25 +4005,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3111
4005
|
|
|
3112
4006
|
try {
|
|
3113
4007
|
const [artists, albums, tracks] = await Promise.all([
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
sectionId: plexState.musicSectionId,
|
|
3118
|
-
}),
|
|
3119
|
-
listAlbums({
|
|
3120
|
-
baseUrl: plexState.baseUrl,
|
|
3121
|
-
plexToken: plexState.plexToken,
|
|
3122
|
-
sectionId: plexState.musicSectionId,
|
|
3123
|
-
}),
|
|
3124
|
-
listTracks({
|
|
3125
|
-
baseUrl: plexState.baseUrl,
|
|
3126
|
-
plexToken: plexState.plexToken,
|
|
3127
|
-
sectionId: plexState.musicSectionId,
|
|
3128
|
-
}),
|
|
4008
|
+
getCachedLibraryArtists({ accountId: account.id, plexState, request }),
|
|
4009
|
+
getCachedLibraryAlbums({ accountId: account.id, plexState, request }),
|
|
4010
|
+
getCachedLibraryTracks({ accountId: account.id, plexState, request }),
|
|
3129
4011
|
]);
|
|
3130
4012
|
|
|
3131
4013
|
const starredArtists = artists
|
|
3132
|
-
.filter((artist) =>
|
|
4014
|
+
.filter((artist) => isPlexLiked(artist.userRating))
|
|
3133
4015
|
.map((artist) =>
|
|
3134
4016
|
emptyNode('artist', {
|
|
3135
4017
|
id: artist.ratingKey,
|
|
@@ -3141,12 +4023,12 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3141
4023
|
.join('');
|
|
3142
4024
|
|
|
3143
4025
|
const starredAlbums = albums
|
|
3144
|
-
.filter((album) =>
|
|
4026
|
+
.filter((album) => isPlexLiked(album.userRating))
|
|
3145
4027
|
.map((album) => emptyNode('album', albumAttrs(album)))
|
|
3146
4028
|
.join('');
|
|
3147
4029
|
|
|
3148
4030
|
const starredSongs = tracks
|
|
3149
|
-
.filter((track) =>
|
|
4031
|
+
.filter((track) => isPlexLiked(track.userRating))
|
|
3150
4032
|
.map((track) => emptyNode('song', songAttrs(track)))
|
|
3151
4033
|
.join('');
|
|
3152
4034
|
|
|
@@ -3171,25 +4053,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3171
4053
|
|
|
3172
4054
|
try {
|
|
3173
4055
|
const [artists, albums, tracks] = await Promise.all([
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
sectionId: plexState.musicSectionId,
|
|
3178
|
-
}),
|
|
3179
|
-
listAlbums({
|
|
3180
|
-
baseUrl: plexState.baseUrl,
|
|
3181
|
-
plexToken: plexState.plexToken,
|
|
3182
|
-
sectionId: plexState.musicSectionId,
|
|
3183
|
-
}),
|
|
3184
|
-
listTracks({
|
|
3185
|
-
baseUrl: plexState.baseUrl,
|
|
3186
|
-
plexToken: plexState.plexToken,
|
|
3187
|
-
sectionId: plexState.musicSectionId,
|
|
3188
|
-
}),
|
|
4056
|
+
getCachedLibraryArtists({ accountId: account.id, plexState, request }),
|
|
4057
|
+
getCachedLibraryAlbums({ accountId: account.id, plexState, request }),
|
|
4058
|
+
getCachedLibraryTracks({ accountId: account.id, plexState, request }),
|
|
3189
4059
|
]);
|
|
3190
4060
|
|
|
3191
4061
|
const starredArtists = artists
|
|
3192
|
-
.filter((artist) =>
|
|
4062
|
+
.filter((artist) => isPlexLiked(artist.userRating))
|
|
3193
4063
|
.map((artist) =>
|
|
3194
4064
|
emptyNode('artist', {
|
|
3195
4065
|
id: artist.ratingKey,
|
|
@@ -3202,12 +4072,12 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3202
4072
|
.join('');
|
|
3203
4073
|
|
|
3204
4074
|
const starredAlbums = albums
|
|
3205
|
-
.filter((album) =>
|
|
4075
|
+
.filter((album) => isPlexLiked(album.userRating))
|
|
3206
4076
|
.map((album) => emptyNode('album', albumId3Attrs(album)))
|
|
3207
4077
|
.join('');
|
|
3208
4078
|
|
|
3209
4079
|
const starredSongs = tracks
|
|
3210
|
-
.filter((track) =>
|
|
4080
|
+
.filter((track) => isPlexLiked(track.userRating))
|
|
3211
4081
|
.map((track) => emptyNode('song', songAttrs(track)))
|
|
3212
4082
|
.join('');
|
|
3213
4083
|
|
|
@@ -3234,16 +4104,16 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3234
4104
|
}
|
|
3235
4105
|
|
|
3236
4106
|
try {
|
|
3237
|
-
const albums = await
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
});
|
|
4107
|
+
const [albums, tracks] = await Promise.all([
|
|
4108
|
+
getCachedLibraryAlbums({ accountId: account.id, plexState, request }),
|
|
4109
|
+
getCachedLibraryTracks({ accountId: account.id, plexState, request }),
|
|
4110
|
+
]);
|
|
3242
4111
|
|
|
4112
|
+
const albumGenreTagMap = buildAlbumGenreTagMap(albums);
|
|
3243
4113
|
const counts = new Map();
|
|
3244
|
-
for (const
|
|
3245
|
-
const
|
|
3246
|
-
for (const tag of
|
|
4114
|
+
for (const track of tracks) {
|
|
4115
|
+
const albumId = String(track?.parentRatingKey || '').trim();
|
|
4116
|
+
for (const tag of resolvedGenreTagsForTrack(track, albumGenreTagMap)) {
|
|
3247
4117
|
const normalized = tag.trim();
|
|
3248
4118
|
if (!normalized) {
|
|
3249
4119
|
continue;
|
|
@@ -3252,10 +4122,12 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3252
4122
|
const current = counts.get(key) || {
|
|
3253
4123
|
name: normalized,
|
|
3254
4124
|
songCount: 0,
|
|
3255
|
-
|
|
4125
|
+
albumIds: new Set(),
|
|
3256
4126
|
};
|
|
3257
|
-
current.songCount +=
|
|
3258
|
-
|
|
4127
|
+
current.songCount += 1;
|
|
4128
|
+
if (albumId) {
|
|
4129
|
+
current.albumIds.add(albumId);
|
|
4130
|
+
}
|
|
3259
4131
|
counts.set(key, current);
|
|
3260
4132
|
}
|
|
3261
4133
|
}
|
|
@@ -3267,7 +4139,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3267
4139
|
'genre',
|
|
3268
4140
|
{
|
|
3269
4141
|
songCount: genre.songCount,
|
|
3270
|
-
albumCount: genre.
|
|
4142
|
+
albumCount: genre.albumIds.size,
|
|
3271
4143
|
},
|
|
3272
4144
|
genre.name,
|
|
3273
4145
|
),
|
|
@@ -3306,34 +4178,22 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3306
4178
|
}
|
|
3307
4179
|
|
|
3308
4180
|
try {
|
|
3309
|
-
const albums = await
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
const
|
|
3316
|
-
|
|
4181
|
+
const [albums, tracks] = await Promise.all([
|
|
4182
|
+
getCachedLibraryAlbums({ accountId: account.id, plexState, request }),
|
|
4183
|
+
getCachedLibraryTracks({ accountId: account.id, plexState, request }),
|
|
4184
|
+
]);
|
|
4185
|
+
|
|
4186
|
+
const targetGenre = safeLower(genre);
|
|
4187
|
+
const albumGenreTagMap = buildAlbumGenreTagMap(albums);
|
|
4188
|
+
const matchedSongs = tracks.filter((track) =>
|
|
4189
|
+
resolvedGenreTagsForTrack(track, albumGenreTagMap).some(
|
|
4190
|
+
(tag) => safeLower(String(tag || '').trim()) === targetGenre,
|
|
4191
|
+
),
|
|
3317
4192
|
);
|
|
3318
4193
|
|
|
3319
|
-
const
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
baseUrl: plexState.baseUrl,
|
|
3323
|
-
plexToken: plexState.plexToken,
|
|
3324
|
-
albumId: album.ratingKey,
|
|
3325
|
-
});
|
|
3326
|
-
|
|
3327
|
-
for (const track of tracks) {
|
|
3328
|
-
songs.push(track);
|
|
3329
|
-
}
|
|
3330
|
-
|
|
3331
|
-
if (songs.length >= offset + count) {
|
|
3332
|
-
break;
|
|
3333
|
-
}
|
|
3334
|
-
}
|
|
3335
|
-
|
|
3336
|
-
const page = takePage(songs, offset, count);
|
|
4194
|
+
const sortedSongs = sortTracksForLibraryBrowse(matchedSongs);
|
|
4195
|
+
const page = takePage(sortedSongs, offset, count)
|
|
4196
|
+
.map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
|
|
3337
4197
|
const songXml = page.map((track) => emptyNode('song', songAttrs(track))).join('');
|
|
3338
4198
|
return sendSubsonicOk(reply, node('songsByGenre', {}, songXml));
|
|
3339
4199
|
} catch (error) {
|
|
@@ -3358,11 +4218,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3358
4218
|
}
|
|
3359
4219
|
|
|
3360
4220
|
try {
|
|
3361
|
-
const allTracks = await
|
|
3362
|
-
baseUrl: plexState.baseUrl,
|
|
3363
|
-
plexToken: plexState.plexToken,
|
|
3364
|
-
sectionId: plexState.musicSectionId,
|
|
3365
|
-
});
|
|
4221
|
+
const allTracks = await getCachedLibraryTracks({ accountId: account.id, plexState, request });
|
|
3366
4222
|
|
|
3367
4223
|
const randomTracks = shuffleInPlace(allTracks.slice()).slice(0, size);
|
|
3368
4224
|
const songXml = randomTracks.map((track) => emptyNode('song', songAttrs(track))).join('');
|
|
@@ -3392,11 +4248,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3392
4248
|
}
|
|
3393
4249
|
|
|
3394
4250
|
try {
|
|
3395
|
-
const artists = await
|
|
3396
|
-
baseUrl: plexState.baseUrl,
|
|
3397
|
-
plexToken: plexState.plexToken,
|
|
3398
|
-
sectionId: plexState.musicSectionId,
|
|
3399
|
-
});
|
|
4251
|
+
const artists = await getCachedLibraryArtists({ accountId: account.id, plexState, request });
|
|
3400
4252
|
|
|
3401
4253
|
const artist =
|
|
3402
4254
|
artists.find((item) => safeLower(item.title) === safeLower(artistName)) ||
|
|
@@ -3410,6 +4262,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3410
4262
|
plexToken: plexState.plexToken,
|
|
3411
4263
|
artistId: artist.ratingKey,
|
|
3412
4264
|
});
|
|
4265
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
|
|
3413
4266
|
|
|
3414
4267
|
const topTracks = tracks.slice(0, size);
|
|
3415
4268
|
const songXml = topTracks.map((track) => emptyNode('song', songAttrs(track))).join('');
|
|
@@ -3531,6 +4384,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3531
4384
|
if (tracks == null) {
|
|
3532
4385
|
return sendSubsonicError(reply, 70, 'Item not found');
|
|
3533
4386
|
}
|
|
4387
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
|
|
3534
4388
|
|
|
3535
4389
|
const songXml = tracks.map((track) => emptyNode('song', songAttrs(track))).join('');
|
|
3536
4390
|
return sendSubsonicOk(reply, node('similarSongs', {}, songXml));
|
|
@@ -3574,6 +4428,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3574
4428
|
if (tracks == null) {
|
|
3575
4429
|
return sendSubsonicError(reply, 70, 'Item not found');
|
|
3576
4430
|
}
|
|
4431
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
|
|
3577
4432
|
|
|
3578
4433
|
const songXml = tracks.map((track) => emptyNode('song', songAttrs(track))).join('');
|
|
3579
4434
|
return sendSubsonicOk(reply, node('similarSongs2', {}, songXml));
|
|
@@ -3602,7 +4457,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3602
4457
|
}
|
|
3603
4458
|
|
|
3604
4459
|
try {
|
|
3605
|
-
|
|
4460
|
+
let track = await getTrack({
|
|
3606
4461
|
baseUrl: plexState.baseUrl,
|
|
3607
4462
|
plexToken: plexState.plexToken,
|
|
3608
4463
|
trackId: id,
|
|
@@ -3611,6 +4466,26 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3611
4466
|
return sendSubsonicError(reply, 70, 'Song not found');
|
|
3612
4467
|
}
|
|
3613
4468
|
|
|
4469
|
+
if (!firstGenreTag(track)) {
|
|
4470
|
+
const albumId = String(track?.parentRatingKey || '').trim();
|
|
4471
|
+
if (albumId) {
|
|
4472
|
+
try {
|
|
4473
|
+
const album = await getAlbum({
|
|
4474
|
+
baseUrl: plexState.baseUrl,
|
|
4475
|
+
plexToken: plexState.plexToken,
|
|
4476
|
+
albumId,
|
|
4477
|
+
});
|
|
4478
|
+
if (album) {
|
|
4479
|
+
const albumGenreTagMap = buildAlbumGenreTagMap([album]);
|
|
4480
|
+
track = withResolvedTrackGenres(track, albumGenreTagMap);
|
|
4481
|
+
}
|
|
4482
|
+
} catch (error) {
|
|
4483
|
+
request.log.debug(error, 'Failed to enrich song genre from album metadata');
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [track] });
|
|
4488
|
+
|
|
3614
4489
|
return sendSubsonicOk(reply, node('song', songAttrs(track)));
|
|
3615
4490
|
} catch (error) {
|
|
3616
4491
|
request.log.error(error, 'Failed to load song');
|
|
@@ -3863,51 +4738,15 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3863
4738
|
let matchedTracks = [];
|
|
3864
4739
|
|
|
3865
4740
|
if (!query) {
|
|
3866
|
-
const browseCacheKey = searchBrowseCacheKey(account.id, plexState);
|
|
3867
4741
|
const [artists, albums, tracks] = await Promise.all([
|
|
3868
4742
|
artistCount > 0
|
|
3869
|
-
?
|
|
3870
|
-
cacheKey: browseCacheKey,
|
|
3871
|
-
collection: 'artists',
|
|
3872
|
-
loader: async () => {
|
|
3873
|
-
const loaded = await listArtists({
|
|
3874
|
-
baseUrl: plexState.baseUrl,
|
|
3875
|
-
plexToken: plexState.plexToken,
|
|
3876
|
-
sectionId: plexState.musicSectionId,
|
|
3877
|
-
});
|
|
3878
|
-
return [...loaded].sort((a, b) =>
|
|
3879
|
-
String(a?.title || '').localeCompare(String(b?.title || '')),
|
|
3880
|
-
);
|
|
3881
|
-
},
|
|
3882
|
-
})
|
|
4743
|
+
? getCachedLibraryArtists({ accountId: account.id, plexState, request })
|
|
3883
4744
|
: [],
|
|
3884
4745
|
albumCount > 0
|
|
3885
|
-
?
|
|
3886
|
-
cacheKey: browseCacheKey,
|
|
3887
|
-
collection: 'albums',
|
|
3888
|
-
loader: async () => {
|
|
3889
|
-
const loaded = await listAlbums({
|
|
3890
|
-
baseUrl: plexState.baseUrl,
|
|
3891
|
-
plexToken: plexState.plexToken,
|
|
3892
|
-
sectionId: plexState.musicSectionId,
|
|
3893
|
-
});
|
|
3894
|
-
return sortAlbumsByName(loaded);
|
|
3895
|
-
},
|
|
3896
|
-
})
|
|
4746
|
+
? getCachedLibraryAlbums({ accountId: account.id, plexState, request })
|
|
3897
4747
|
: [],
|
|
3898
4748
|
songCount > 0
|
|
3899
|
-
?
|
|
3900
|
-
cacheKey: browseCacheKey,
|
|
3901
|
-
collection: 'tracks',
|
|
3902
|
-
loader: async () => {
|
|
3903
|
-
const loaded = await listTracks({
|
|
3904
|
-
baseUrl: plexState.baseUrl,
|
|
3905
|
-
plexToken: plexState.plexToken,
|
|
3906
|
-
sectionId: plexState.musicSectionId,
|
|
3907
|
-
});
|
|
3908
|
-
return sortTracksForLibraryBrowse(loaded);
|
|
3909
|
-
},
|
|
3910
|
-
})
|
|
4749
|
+
? getCachedLibraryTracks({ accountId: account.id, plexState, request })
|
|
3911
4750
|
: [],
|
|
3912
4751
|
]);
|
|
3913
4752
|
|
|
@@ -4342,14 +5181,24 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4342
5181
|
return;
|
|
4343
5182
|
}
|
|
4344
5183
|
|
|
5184
|
+
const cacheKey = searchBrowseCacheKey(account.id, plexState);
|
|
5185
|
+
const cacheEntry = searchBrowseCache.get(cacheKey);
|
|
5186
|
+
const actions = ids.map((id) => {
|
|
5187
|
+
const currentRating = getCachedUserRatingForItem(cacheEntry, id);
|
|
5188
|
+
return {
|
|
5189
|
+
id,
|
|
5190
|
+
targetRating: toLikedPlexRating(currentRating),
|
|
5191
|
+
};
|
|
5192
|
+
});
|
|
5193
|
+
|
|
4345
5194
|
try {
|
|
4346
5195
|
await Promise.all(
|
|
4347
|
-
|
|
5196
|
+
actions.map(({ id, targetRating }) =>
|
|
4348
5197
|
ratePlexItem({
|
|
4349
5198
|
baseUrl: plexState.baseUrl,
|
|
4350
5199
|
plexToken: plexState.plexToken,
|
|
4351
5200
|
itemId: id,
|
|
4352
|
-
rating:
|
|
5201
|
+
rating: targetRating,
|
|
4353
5202
|
}),
|
|
4354
5203
|
),
|
|
4355
5204
|
);
|
|
@@ -4358,6 +5207,18 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4358
5207
|
return sendSubsonicError(reply, 10, 'Failed to star');
|
|
4359
5208
|
}
|
|
4360
5209
|
|
|
5210
|
+
let patchedCount = 0;
|
|
5211
|
+
for (const { id, targetRating } of actions) {
|
|
5212
|
+
patchedCount += applyUserRatingPatchToSearchBrowseCache({
|
|
5213
|
+
cacheKey,
|
|
5214
|
+
itemIds: [id],
|
|
5215
|
+
userRating: targetRating,
|
|
5216
|
+
});
|
|
5217
|
+
}
|
|
5218
|
+
if (patchedCount === 0) {
|
|
5219
|
+
markSearchBrowseCacheDirty(cacheKey);
|
|
5220
|
+
}
|
|
5221
|
+
|
|
4361
5222
|
return sendSubsonicOk(reply);
|
|
4362
5223
|
},
|
|
4363
5224
|
});
|
|
@@ -4387,14 +5248,24 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4387
5248
|
return;
|
|
4388
5249
|
}
|
|
4389
5250
|
|
|
5251
|
+
const cacheKey = searchBrowseCacheKey(account.id, plexState);
|
|
5252
|
+
const cacheEntry = searchBrowseCache.get(cacheKey);
|
|
5253
|
+
const actions = ids.map((id) => {
|
|
5254
|
+
const currentRating = getCachedUserRatingForItem(cacheEntry, id);
|
|
5255
|
+
return {
|
|
5256
|
+
id,
|
|
5257
|
+
targetRating: toUnlikedPlexRating(currentRating),
|
|
5258
|
+
};
|
|
5259
|
+
});
|
|
5260
|
+
|
|
4390
5261
|
try {
|
|
4391
5262
|
await Promise.all(
|
|
4392
|
-
|
|
5263
|
+
actions.map(({ id, targetRating }) =>
|
|
4393
5264
|
ratePlexItem({
|
|
4394
5265
|
baseUrl: plexState.baseUrl,
|
|
4395
5266
|
plexToken: plexState.plexToken,
|
|
4396
5267
|
itemId: id,
|
|
4397
|
-
rating:
|
|
5268
|
+
rating: targetRating,
|
|
4398
5269
|
}),
|
|
4399
5270
|
),
|
|
4400
5271
|
);
|
|
@@ -4403,6 +5274,18 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4403
5274
|
return sendSubsonicError(reply, 10, 'Failed to unstar');
|
|
4404
5275
|
}
|
|
4405
5276
|
|
|
5277
|
+
let patchedCount = 0;
|
|
5278
|
+
for (const { id, targetRating } of actions) {
|
|
5279
|
+
patchedCount += applyUserRatingPatchToSearchBrowseCache({
|
|
5280
|
+
cacheKey,
|
|
5281
|
+
itemIds: [id],
|
|
5282
|
+
userRating: targetRating,
|
|
5283
|
+
});
|
|
5284
|
+
}
|
|
5285
|
+
if (patchedCount === 0) {
|
|
5286
|
+
markSearchBrowseCacheDirty(cacheKey);
|
|
5287
|
+
}
|
|
5288
|
+
|
|
4406
5289
|
return sendSubsonicOk(reply);
|
|
4407
5290
|
},
|
|
4408
5291
|
});
|
|
@@ -4434,7 +5317,11 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4434
5317
|
return;
|
|
4435
5318
|
}
|
|
4436
5319
|
|
|
4437
|
-
const
|
|
5320
|
+
const cacheKey = searchBrowseCacheKey(account.id, plexState);
|
|
5321
|
+
const cacheEntry = searchBrowseCache.get(cacheKey);
|
|
5322
|
+
const currentRating = getCachedUserRatingForItem(cacheEntry, id);
|
|
5323
|
+
const preserveLike = rating >= 2 && isPlexLiked(currentRating);
|
|
5324
|
+
const plexRating = subsonicRatingToPlexRating(rating, { liked: preserveLike });
|
|
4438
5325
|
try {
|
|
4439
5326
|
await ratePlexItem({
|
|
4440
5327
|
baseUrl: plexState.baseUrl,
|
|
@@ -4447,6 +5334,15 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4447
5334
|
return sendSubsonicError(reply, 10, 'Failed to set rating');
|
|
4448
5335
|
}
|
|
4449
5336
|
|
|
5337
|
+
const patchedCount = applyUserRatingPatchToSearchBrowseCache({
|
|
5338
|
+
cacheKey,
|
|
5339
|
+
itemIds: [id],
|
|
5340
|
+
userRating: plexRating,
|
|
5341
|
+
});
|
|
5342
|
+
if (patchedCount === 0) {
|
|
5343
|
+
markSearchBrowseCacheDirty(cacheKey);
|
|
5344
|
+
}
|
|
5345
|
+
|
|
4450
5346
|
return sendSubsonicOk(reply);
|
|
4451
5347
|
},
|
|
4452
5348
|
});
|
|
@@ -4812,11 +5708,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4812
5708
|
}
|
|
4813
5709
|
|
|
4814
5710
|
try {
|
|
4815
|
-
const artists = await
|
|
4816
|
-
baseUrl: plexState.baseUrl,
|
|
4817
|
-
plexToken: plexState.plexToken,
|
|
4818
|
-
sectionId: plexState.musicSectionId,
|
|
4819
|
-
});
|
|
5711
|
+
const artists = await getCachedLibraryArtists({ accountId: account.id, plexState, request });
|
|
4820
5712
|
|
|
4821
5713
|
const indexes = groupArtistsForSubsonic(artists).join('');
|
|
4822
5714
|
return sendSubsonicOk(reply, node('artists', { ignoredArticles: 'The El La Los Las Le Les' }, indexes));
|
|
@@ -4898,30 +5790,51 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4898
5790
|
}
|
|
4899
5791
|
|
|
4900
5792
|
try {
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
plexToken: plexState.plexToken,
|
|
4904
|
-
artistId,
|
|
4905
|
-
});
|
|
5793
|
+
let artist = null;
|
|
5794
|
+
let finalAlbums = [];
|
|
4906
5795
|
|
|
4907
|
-
|
|
4908
|
-
|
|
5796
|
+
try {
|
|
5797
|
+
artist = await getArtist({
|
|
5798
|
+
baseUrl: plexState.baseUrl,
|
|
5799
|
+
plexToken: plexState.plexToken,
|
|
5800
|
+
artistId,
|
|
5801
|
+
});
|
|
5802
|
+
} catch (error) {
|
|
5803
|
+
if (!isPlexNotFoundError(error)) {
|
|
5804
|
+
throw error;
|
|
5805
|
+
}
|
|
4909
5806
|
}
|
|
4910
5807
|
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
artistId,
|
|
4915
|
-
});
|
|
4916
|
-
|
|
4917
|
-
let finalAlbums = albums;
|
|
4918
|
-
if (finalAlbums.length === 0) {
|
|
4919
|
-
const tracks = await listArtistTracks({
|
|
5808
|
+
if (artist) {
|
|
5809
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
|
|
5810
|
+
const albums = await listArtistAlbums({
|
|
4920
5811
|
baseUrl: plexState.baseUrl,
|
|
4921
5812
|
plexToken: plexState.plexToken,
|
|
4922
5813
|
artistId,
|
|
4923
5814
|
});
|
|
4924
|
-
|
|
5815
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
|
|
5816
|
+
finalAlbums = albums;
|
|
5817
|
+
if (finalAlbums.length === 0) {
|
|
5818
|
+
const tracks = await listArtistTracks({
|
|
5819
|
+
baseUrl: plexState.baseUrl,
|
|
5820
|
+
plexToken: plexState.plexToken,
|
|
5821
|
+
artistId,
|
|
5822
|
+
});
|
|
5823
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
|
|
5824
|
+
finalAlbums = deriveAlbumsFromTracks(tracks, artist.ratingKey, artist.title);
|
|
5825
|
+
}
|
|
5826
|
+
} else {
|
|
5827
|
+
const fallback = await resolveArtistFromCachedLibrary({
|
|
5828
|
+
accountId: account.id,
|
|
5829
|
+
plexState,
|
|
5830
|
+
request,
|
|
5831
|
+
artistId,
|
|
5832
|
+
});
|
|
5833
|
+
if (!fallback?.artist) {
|
|
5834
|
+
return sendSubsonicError(reply, 70, 'Artist not found');
|
|
5835
|
+
}
|
|
5836
|
+
artist = fallback.artist;
|
|
5837
|
+
finalAlbums = fallback.albums || [];
|
|
4925
5838
|
}
|
|
4926
5839
|
|
|
4927
5840
|
const albumXml = finalAlbums
|
|
@@ -5005,6 +5918,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5005
5918
|
tracks,
|
|
5006
5919
|
request,
|
|
5007
5920
|
});
|
|
5921
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [album, ...tracksWithGenre] });
|
|
5008
5922
|
const sortedTracks = sortTracksByDiscAndIndex(tracksWithGenre);
|
|
5009
5923
|
|
|
5010
5924
|
const totalDuration = sortedTracks.reduce((sum, track) => sum + durationSeconds(track.duration), 0);
|
|
@@ -5062,6 +5976,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5062
5976
|
sectionId: plexState.musicSectionId,
|
|
5063
5977
|
folderPath: explicitFolderPath,
|
|
5064
5978
|
});
|
|
5979
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: folderResult.items });
|
|
5065
5980
|
|
|
5066
5981
|
const currentFolderPath = isRootFolder ? null : explicitFolderPath;
|
|
5067
5982
|
const currentDirectoryId = isRootFolder
|
|
@@ -5125,18 +6040,27 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5125
6040
|
);
|
|
5126
6041
|
}
|
|
5127
6042
|
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
6043
|
+
let artist = null;
|
|
6044
|
+
try {
|
|
6045
|
+
artist = await getArtist({
|
|
6046
|
+
baseUrl: plexState.baseUrl,
|
|
6047
|
+
plexToken: plexState.plexToken,
|
|
6048
|
+
artistId: id,
|
|
6049
|
+
});
|
|
6050
|
+
} catch (error) {
|
|
6051
|
+
if (!isPlexNotFoundError(error)) {
|
|
6052
|
+
throw error;
|
|
6053
|
+
}
|
|
6054
|
+
}
|
|
5133
6055
|
|
|
5134
6056
|
if (artist) {
|
|
6057
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
|
|
5135
6058
|
const albums = await listArtistAlbums({
|
|
5136
6059
|
baseUrl: plexState.baseUrl,
|
|
5137
6060
|
plexToken: plexState.plexToken,
|
|
5138
6061
|
artistId: id,
|
|
5139
6062
|
});
|
|
6063
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
|
|
5140
6064
|
|
|
5141
6065
|
let finalAlbums = albums;
|
|
5142
6066
|
if (finalAlbums.length === 0) {
|
|
@@ -5166,6 +6090,34 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5166
6090
|
);
|
|
5167
6091
|
}
|
|
5168
6092
|
|
|
6093
|
+
if (!artist) {
|
|
6094
|
+
const fallback = await resolveArtistFromCachedLibrary({
|
|
6095
|
+
accountId: account.id,
|
|
6096
|
+
plexState,
|
|
6097
|
+
request,
|
|
6098
|
+
artistId: id,
|
|
6099
|
+
});
|
|
6100
|
+
if (fallback?.artist) {
|
|
6101
|
+
const fallbackAlbums = fallback.albums || [];
|
|
6102
|
+
const children = fallbackAlbums
|
|
6103
|
+
.map((album) => emptyNode('child', albumAttrs(album, fallback.artist.ratingKey, fallback.artist.title)))
|
|
6104
|
+
.join('');
|
|
6105
|
+
|
|
6106
|
+
return sendSubsonicOk(
|
|
6107
|
+
reply,
|
|
6108
|
+
node(
|
|
6109
|
+
'directory',
|
|
6110
|
+
{
|
|
6111
|
+
id: fallback.artist.ratingKey,
|
|
6112
|
+
parent: rootDirectoryId,
|
|
6113
|
+
name: fallback.artist.title,
|
|
6114
|
+
},
|
|
6115
|
+
children,
|
|
6116
|
+
),
|
|
6117
|
+
);
|
|
6118
|
+
}
|
|
6119
|
+
}
|
|
6120
|
+
|
|
5169
6121
|
const album = await getAlbum({
|
|
5170
6122
|
baseUrl: plexState.baseUrl,
|
|
5171
6123
|
plexToken: plexState.plexToken,
|
|
@@ -5184,6 +6136,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5184
6136
|
tracks,
|
|
5185
6137
|
request,
|
|
5186
6138
|
});
|
|
6139
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [album, ...tracksWithGenre] });
|
|
5187
6140
|
const sortedTracks = sortTracksByDiscAndIndex(tracksWithGenre);
|
|
5188
6141
|
|
|
5189
6142
|
const children = sortedTracks
|
|
@@ -5259,11 +6212,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5259
6212
|
}
|
|
5260
6213
|
|
|
5261
6214
|
try {
|
|
5262
|
-
const allAlbums = await
|
|
5263
|
-
baseUrl: plexState.baseUrl,
|
|
5264
|
-
plexToken: plexState.plexToken,
|
|
5265
|
-
sectionId: plexState.musicSectionId,
|
|
5266
|
-
});
|
|
6215
|
+
const allAlbums = await getCachedLibraryAlbums({ accountId: account.id, plexState, request });
|
|
5267
6216
|
|
|
5268
6217
|
const filtered = filterAndSortAlbumList(allAlbums, {
|
|
5269
6218
|
type,
|