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/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 plexRatingToSubsonic(value) {
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
- return Math.round(normalized / 2);
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 >= 9) {
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 genreTags = allGenreTags(track);
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) => normalizePlexRating(album?.userRating) >= 9);
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 SEARCH_BROWSE_CACHE_TTL_MS = 15000;
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.expiresAt <= now) {
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]?.expiresAt || 0) - (b[1]?.expiresAt || 0));
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
- function searchBrowseCacheKey(accountId, plexState) {
2084
- return `${accountId}:${plexState.machineId}:${plexState.musicSectionId}`;
2852
+ return loadSearchBrowseCollection({
2853
+ entry,
2854
+ collection,
2855
+ loader,
2856
+ request,
2857
+ background: false,
2858
+ });
2085
2859
  }
2086
2860
 
2087
- function getSearchBrowseCacheEntry(cacheKey) {
2088
- const now = Date.now();
2089
- const existing = searchBrowseCache.get(cacheKey);
2090
- if (existing && existing.expiresAt > now) {
2091
- existing.expiresAt = now + SEARCH_BROWSE_CACHE_TTL_MS;
2092
- return existing;
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
- if (existing) {
2096
- searchBrowseCache.delete(cacheKey);
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
- const created = {
2100
- expiresAt: now + SEARCH_BROWSE_CACHE_TTL_MS,
2101
- artists: null,
2102
- albums: null,
2103
- tracks: null,
2104
- loadingArtists: null,
2105
- loadingAlbums: null,
2106
- loadingTracks: null,
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
- async function getSearchBrowseCollection({ cacheKey, collection, loader }) {
2114
- const entry = getSearchBrowseCacheEntry(cacheKey);
2115
- if (Array.isArray(entry[collection])) {
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
- const loadingKey = `loading${collection[0].toUpperCase()}${collection.slice(1)}`;
2120
- if (entry[loadingKey]) {
2121
- return entry[loadingKey];
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 pending = (async () => {
2125
- const loaded = await loader();
2126
- return Array.isArray(loaded) ? loaded : [];
2127
- })();
2128
- entry[loadingKey] = pending;
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
- try {
2131
- const loaded = await pending;
2132
- entry[collection] = loaded;
2133
- entry.expiresAt = Date.now() + SEARCH_BROWSE_CACHE_TTL_MS;
2134
- return loaded;
2135
- } finally {
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
- return sendSubsonicOk(
3077
- reply,
3078
- emptyNode('scanStatus', {
3079
- scanning: false,
3080
- count: 0,
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
- return sendSubsonicOk(
3092
- reply,
3093
- emptyNode('scanStatus', {
3094
- scanning: false,
3095
- count: 0,
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
- listArtists({
3115
- baseUrl: plexState.baseUrl,
3116
- plexToken: plexState.plexToken,
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) => normalizePlexRating(artist.userRating) >= 9)
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) => normalizePlexRating(album.userRating) >= 9)
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) => normalizePlexRating(track.userRating) >= 9)
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
- listArtists({
3175
- baseUrl: plexState.baseUrl,
3176
- plexToken: plexState.plexToken,
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) => normalizePlexRating(artist.userRating) >= 9)
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) => normalizePlexRating(album.userRating) >= 9)
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) => normalizePlexRating(track.userRating) >= 9)
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 listAlbums({
3238
- baseUrl: plexState.baseUrl,
3239
- plexToken: plexState.plexToken,
3240
- sectionId: plexState.musicSectionId,
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 album of albums) {
3245
- const albumSongCount = Math.max(1, Number.parseInt(String(album?.leafCount ?? ''), 10) || 0);
3246
- for (const tag of allGenreTags(album)) {
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
- albumCount: 0,
4125
+ albumIds: new Set(),
3256
4126
  };
3257
- current.songCount += albumSongCount;
3258
- current.albumCount += 1;
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.albumCount,
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 listAlbums({
3310
- baseUrl: plexState.baseUrl,
3311
- plexToken: plexState.plexToken,
3312
- sectionId: plexState.musicSectionId,
3313
- });
3314
-
3315
- const matchedAlbums = albums.filter((album) =>
3316
- allGenreTags(album).some((tag) => safeLower(tag.trim()) === safeLower(genre)),
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 songs = [];
3320
- for (const album of matchedAlbums) {
3321
- const tracks = await listAlbumTracks({
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 listTracks({
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 listArtists({
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
- const track = await getTrack({
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
- ? getSearchBrowseCollection({
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
- ? getSearchBrowseCollection({
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
- ? getSearchBrowseCollection({
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
- ids.map((id) =>
5196
+ actions.map(({ id, targetRating }) =>
4348
5197
  ratePlexItem({
4349
5198
  baseUrl: plexState.baseUrl,
4350
5199
  plexToken: plexState.plexToken,
4351
5200
  itemId: id,
4352
- rating: 10,
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
- ids.map((id) =>
5263
+ actions.map(({ id, targetRating }) =>
4393
5264
  ratePlexItem({
4394
5265
  baseUrl: plexState.baseUrl,
4395
5266
  plexToken: plexState.plexToken,
4396
5267
  itemId: id,
4397
- rating: 0,
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 plexRating = Math.round((rating / 5) * 10);
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 listArtists({
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
- const artist = await getArtist({
4902
- baseUrl: plexState.baseUrl,
4903
- plexToken: plexState.plexToken,
4904
- artistId,
4905
- });
5793
+ let artist = null;
5794
+ let finalAlbums = [];
4906
5795
 
4907
- if (!artist) {
4908
- return sendSubsonicError(reply, 70, 'Artist not found');
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
- const albums = await listArtistAlbums({
4912
- baseUrl: plexState.baseUrl,
4913
- plexToken: plexState.plexToken,
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
- finalAlbums = deriveAlbumsFromTracks(tracks, artist.ratingKey, artist.title);
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
- const artist = await getArtist({
5129
- baseUrl: plexState.baseUrl,
5130
- plexToken: plexState.plexToken,
5131
- artistId: id,
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 listAlbums({
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,