plexsonic 0.1.1 → 0.1.3

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,6 +62,7 @@ import {
61
62
  listMusicSections,
62
63
  listPlexServers,
63
64
  pollPlexPin,
65
+ probeSectionFingerprint,
64
66
  ratePlexItem,
65
67
  removePlexPlaylistItems,
66
68
  renamePlexPlaylist,
@@ -89,6 +91,14 @@ const DEFAULT_CORS_ALLOW_HEADERS = [
89
91
  ].join(', ');
90
92
  const DEFAULT_CORS_ALLOW_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
91
93
  const DEFAULT_CORS_EXPOSE_HEADERS = 'content-type, content-length, content-range, accept-ranges, etag, last-modified';
94
+ const CACHE_INVALIDATING_PLEX_MEDIA_EVENTS = new Set([
95
+ 'media.add',
96
+ 'media.delete',
97
+ ]);
98
+ const CACHE_PATCHABLE_PLEX_MEDIA_EVENTS = new Set([
99
+ 'media.rate',
100
+ 'media.unrate',
101
+ ]);
92
102
 
93
103
  function applyCorsHeaders(request, reply) {
94
104
  const origin = firstForwardedValue(request.headers?.origin);
@@ -170,6 +180,133 @@ function getBodyFirst(request, key) {
170
180
  return '';
171
181
  }
172
182
 
183
+ function getBodyFieldValue(body, key) {
184
+ const value = body?.[key];
185
+ if (typeof value === 'string') {
186
+ return value;
187
+ }
188
+ if (value && typeof value === 'object' && typeof value.value === 'string') {
189
+ return value.value;
190
+ }
191
+ return '';
192
+ }
193
+
194
+ function parsePlexWebhookPayload(body) {
195
+ const parseMaybeJson = (value) => {
196
+ if (!value || typeof value !== 'string') {
197
+ return null;
198
+ }
199
+ try {
200
+ return JSON.parse(value);
201
+ } catch {
202
+ return null;
203
+ }
204
+ };
205
+
206
+ if (!body) {
207
+ return null;
208
+ }
209
+
210
+ if (typeof body === 'string') {
211
+ return parseMaybeJson(body);
212
+ }
213
+
214
+ if (typeof body !== 'object') {
215
+ return null;
216
+ }
217
+
218
+ const payloadField = getBodyFieldValue(body, 'payload');
219
+ const parsedPayload = parseMaybeJson(payloadField);
220
+ if (parsedPayload && typeof parsedPayload === 'object') {
221
+ return parsedPayload;
222
+ }
223
+
224
+ if (body.event || body.Metadata || body.Account || body.Server) {
225
+ return body;
226
+ }
227
+
228
+ return null;
229
+ }
230
+
231
+ function isMusicWebhookPayload(payload) {
232
+ const metadata = payload?.Metadata || payload?.metadata || null;
233
+ if (!metadata) {
234
+ return true;
235
+ }
236
+
237
+ const sectionType = safeLower(metadata.librarySectionType);
238
+ if (sectionType) {
239
+ return sectionType === 'music' || sectionType === 'artist';
240
+ }
241
+
242
+ const metadataType = safeLower(metadata.type);
243
+ if (!metadataType) {
244
+ return true;
245
+ }
246
+
247
+ return metadataType === 'track' ||
248
+ metadataType === 'album' ||
249
+ metadataType === 'artist' ||
250
+ metadataType === 'playlist';
251
+ }
252
+
253
+ function shouldInvalidateCacheForPlexWebhook(payload) {
254
+ if (!payload || !isMusicWebhookPayload(payload)) {
255
+ return false;
256
+ }
257
+
258
+ const event = safeLower(payload.event);
259
+ if (!event) {
260
+ return false;
261
+ }
262
+
263
+ if (event.startsWith('library.')) {
264
+ return true;
265
+ }
266
+
267
+ return CACHE_INVALIDATING_PLEX_MEDIA_EVENTS.has(event);
268
+ }
269
+
270
+ function isRatingPatchableWebhookEvent(event) {
271
+ return CACHE_PATCHABLE_PLEX_MEDIA_EVENTS.has(safeLower(event));
272
+ }
273
+
274
+ function extractRatingPatchFromWebhook(payload) {
275
+ if (!payload) {
276
+ return null;
277
+ }
278
+
279
+ const event = safeLower(payload.event);
280
+ if (!isRatingPatchableWebhookEvent(event)) {
281
+ return null;
282
+ }
283
+
284
+ const metadata = payload.Metadata || payload.metadata || {};
285
+ const ratingKey = String(metadata.ratingKey || '').trim();
286
+ if (!ratingKey) {
287
+ return null;
288
+ }
289
+
290
+ if (event === 'media.unrate') {
291
+ return {
292
+ itemIds: [ratingKey],
293
+ userRating: 0,
294
+ };
295
+ }
296
+
297
+ const explicitRating = normalizePlexRating(
298
+ metadata.userRating ?? metadata.rating ?? payload.userRating ?? payload.rating,
299
+ );
300
+ if (explicitRating != null) {
301
+ return {
302
+ itemIds: [ratingKey],
303
+ userRating: explicitRating,
304
+ };
305
+ }
306
+
307
+ return null;
308
+ }
309
+
173
310
  function getRequestParam(request, key) {
174
311
  const fromQuery = getQueryFirst(request, key);
175
312
  if (fromQuery) {
@@ -384,6 +521,31 @@ function decodePasswordParam(rawPassword) {
384
521
  }
385
522
  }
386
523
 
524
+ function syncStoredSubsonicPassword(repo, tokenCipher, account, clearPassword) {
525
+ const normalizedPassword = String(clearPassword || '');
526
+ if (!account || !account.id || !normalizedPassword) {
527
+ return;
528
+ }
529
+
530
+ let shouldUpdate = false;
531
+ if (!account.subsonic_password_enc) {
532
+ shouldUpdate = true;
533
+ } else {
534
+ try {
535
+ const decrypted = tokenCipher.decrypt(account.subsonic_password_enc);
536
+ if (decrypted !== normalizedPassword) {
537
+ shouldUpdate = true;
538
+ }
539
+ } catch {
540
+ shouldUpdate = true;
541
+ }
542
+ }
543
+
544
+ if (shouldUpdate) {
545
+ repo.updateSubsonicPasswordEnc(account.id, tokenCipher.encrypt(normalizedPassword));
546
+ }
547
+ }
548
+
387
549
  async function authenticateSubsonicRequest(request, reply, repo, tokenCipher) {
388
550
  const username = normalizeUsername(getRequestParam(request, 'u'));
389
551
  const passwordRaw = normalizePassword(getRequestParam(request, 'p'));
@@ -457,9 +619,7 @@ async function authenticateSubsonicRequest(request, reply, repo, tokenCipher) {
457
619
  return null;
458
620
  }
459
621
 
460
- if (!account.subsonic_password_enc) {
461
- repo.updateSubsonicPasswordEnc(account.id, tokenCipher.encrypt(decodedPassword));
462
- }
622
+ syncStoredSubsonicPassword(repo, tokenCipher, account, decodedPassword);
463
623
 
464
624
  return account;
465
625
  }
@@ -694,12 +854,56 @@ function normalizePlexRating(value) {
694
854
  return Math.max(0, Math.min(parsed, 10));
695
855
  }
696
856
 
697
- function plexRatingToSubsonic(value) {
857
+ function normalizePlexRatingInt(value) {
698
858
  const normalized = normalizePlexRating(value);
859
+ if (normalized == null) {
860
+ return null;
861
+ }
862
+ return Math.round(normalized);
863
+ }
864
+
865
+ function isPlexLiked(value) {
866
+ const normalized = normalizePlexRatingInt(value);
867
+ return normalized != null && normalized >= 2 && normalized % 2 === 0;
868
+ }
869
+
870
+ function subsonicRatingToPlexRating(value, { liked = false } = {}) {
871
+ const rating = Number.parseInt(String(value ?? ''), 10);
872
+ if (!Number.isFinite(rating) || rating <= 0) {
873
+ return 0;
874
+ }
875
+ const bounded = Math.max(1, Math.min(5, rating));
876
+ const stars = liked ? Math.max(1, bounded) : bounded;
877
+ return liked ? stars * 2 : (stars * 2) - 1;
878
+ }
879
+
880
+ function toLikedPlexRating(value) {
881
+ const normalized = normalizePlexRatingInt(value);
882
+ if (normalized == null || normalized <= 0) {
883
+ return 2;
884
+ }
885
+ const stars = Math.max(1, Math.min(5, Math.ceil(normalized / 2)));
886
+ return stars * 2;
887
+ }
888
+
889
+ function toUnlikedPlexRating(value) {
890
+ const normalized = normalizePlexRatingInt(value);
891
+ if (normalized == null || normalized <= 0) {
892
+ return 0;
893
+ }
894
+ const stars = Math.max(1, Math.min(5, Math.ceil(normalized / 2)));
895
+ return (stars * 2) - 1;
896
+ }
897
+
898
+ function plexRatingToSubsonic(value) {
899
+ const normalized = normalizePlexRatingInt(value);
699
900
  if (normalized == null) {
700
901
  return undefined;
701
902
  }
702
- return Math.round(normalized / 2);
903
+ if (normalized <= 0) {
904
+ return 0;
905
+ }
906
+ return Math.max(1, Math.min(5, Math.ceil(normalized / 2)));
703
907
  }
704
908
 
705
909
  function subsonicRatingAttrs(item) {
@@ -712,7 +916,7 @@ function subsonicRatingAttrs(item) {
712
916
  userRating: plexRatingToSubsonic(plexRating),
713
917
  };
714
918
 
715
- if (plexRating >= 9) {
919
+ if (isPlexLiked(plexRating)) {
716
920
  attrs.starred = toIsoFromEpochSeconds(item?.updatedAt || item?.addedAt);
717
921
  }
718
922
 
@@ -1670,7 +1874,7 @@ function filterAndSortAlbumList(albums, { type, fromYear, toYear, genre }) {
1670
1874
  });
1671
1875
  break;
1672
1876
  case 'starred':
1673
- list = list.filter((album) => normalizePlexRating(album?.userRating) >= 9);
1877
+ list = list.filter((album) => isPlexLiked(album?.userRating));
1674
1878
  list.sort((a, b) => {
1675
1879
  const tsA = albumTimestampValue(a, ['updatedAt', 'addedAt']);
1676
1880
  const tsB = albumTimestampValue(b, ['updatedAt', 'addedAt']);
@@ -1752,6 +1956,33 @@ function allGenreTags(item) {
1752
1956
  return [...new Set(tags)];
1753
1957
  }
1754
1958
 
1959
+ function buildAlbumGenreTagMap(albums) {
1960
+ const map = new Map();
1961
+ for (const album of Array.isArray(albums) ? albums : []) {
1962
+ const albumId = String(album?.ratingKey || '').trim();
1963
+ if (!albumId) {
1964
+ continue;
1965
+ }
1966
+ map.set(albumId, allGenreTags(album));
1967
+ }
1968
+ return map;
1969
+ }
1970
+
1971
+ function resolvedGenreTagsForTrack(track, albumGenreTagMap) {
1972
+ const directTags = allGenreTags(track);
1973
+ if (directTags.length > 0) {
1974
+ return directTags;
1975
+ }
1976
+
1977
+ const albumId = String(track?.parentRatingKey || '').trim();
1978
+ if (!albumId) {
1979
+ return [];
1980
+ }
1981
+
1982
+ const albumTags = albumGenreTagMap.get(albumId);
1983
+ return Array.isArray(albumTags) ? albumTags : [];
1984
+ }
1985
+
1755
1986
  function firstGenreTag(item) {
1756
1987
  const tags = allGenreTags(item);
1757
1988
  return tags[0] || null;
@@ -2000,6 +2231,9 @@ export async function buildServer(config = loadConfig()) {
2000
2231
 
2001
2232
  await app.register(fastifyCookie);
2002
2233
  await app.register(fastifyFormbody);
2234
+ await app.register(fastifyMultipart, {
2235
+ attachFieldsToBody: true,
2236
+ });
2003
2237
  const sessionStore = createSqliteSessionStore(db, app.log);
2004
2238
  await app.register(fastifySession, {
2005
2239
  secret: config.sessionSecret,
@@ -2033,12 +2267,16 @@ export async function buildServer(config = loadConfig()) {
2033
2267
  const STREAM_PROGRESS_HEARTBEAT_MS = 10000;
2034
2268
  const activeSearchRequests = new Map();
2035
2269
  const searchBrowseCache = new Map();
2036
- const SEARCH_BROWSE_CACHE_TTL_MS = 15000;
2270
+ const SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS = 15000;
2271
+ const SEARCH_BROWSE_CHANGE_CHECK_DEBOUNCE_MS = 15000;
2272
+ const SEARCH_BROWSE_CACHE_IDLE_TTL_MS = 10 * 60 * 1000;
2273
+ const SEARCH_BROWSE_RATING_OVERRIDE_TTL_MS = 2 * 60 * 1000;
2037
2274
  const SEARCH_BROWSE_CACHE_MAX_ENTRIES = 16;
2275
+ const SEARCH_BROWSE_COLLECTIONS = ['artists', 'albums', 'tracks'];
2038
2276
 
2039
2277
  function pruneSearchBrowseCache(now = Date.now()) {
2040
2278
  for (const [cacheKey, entry] of searchBrowseCache.entries()) {
2041
- if (!entry || entry.expiresAt <= now) {
2279
+ if (!entry || (now - Number(entry.lastAccessAt || 0)) > SEARCH_BROWSE_CACHE_IDLE_TTL_MS) {
2042
2280
  searchBrowseCache.delete(cacheKey);
2043
2281
  }
2044
2282
  }
@@ -2048,7 +2286,7 @@ export async function buildServer(config = loadConfig()) {
2048
2286
  }
2049
2287
 
2050
2288
  const sortedByExpiry = [...searchBrowseCache.entries()]
2051
- .sort((a, b) => (a[1]?.expiresAt || 0) - (b[1]?.expiresAt || 0));
2289
+ .sort((a, b) => (a[1]?.lastAccessAt || 0) - (b[1]?.lastAccessAt || 0));
2052
2290
  for (const [cacheKey] of sortedByExpiry) {
2053
2291
  if (searchBrowseCache.size <= SEARCH_BROWSE_CACHE_MAX_ENTRIES) {
2054
2292
  break;
@@ -2061,59 +2299,572 @@ export async function buildServer(config = loadConfig()) {
2061
2299
  return `${accountId}:${plexState.machineId}:${plexState.musicSectionId}`;
2062
2300
  }
2063
2301
 
2064
- function getSearchBrowseCacheEntry(cacheKey) {
2065
- const now = Date.now();
2066
- const existing = searchBrowseCache.get(cacheKey);
2067
- if (existing && existing.expiresAt > now) {
2068
- existing.expiresAt = now + SEARCH_BROWSE_CACHE_TTL_MS;
2069
- return existing;
2070
- }
2302
+ function createSearchBrowseCollectionState() {
2303
+ return {
2304
+ data: null,
2305
+ loading: null,
2306
+ loadedAt: 0,
2307
+ lastRefreshAt: 0,
2308
+ };
2309
+ }
2071
2310
 
2311
+ function getSearchBrowseCacheEntry(cacheKey, now = Date.now()) {
2312
+ const existing = searchBrowseCache.get(cacheKey);
2072
2313
  if (existing) {
2073
- searchBrowseCache.delete(cacheKey);
2314
+ existing.lastAccessAt = now;
2315
+ return existing;
2074
2316
  }
2075
2317
 
2076
2318
  const created = {
2077
- expiresAt: now + SEARCH_BROWSE_CACHE_TTL_MS,
2078
- artists: null,
2079
- albums: null,
2080
- tracks: null,
2081
- loadingArtists: null,
2082
- loadingAlbums: null,
2083
- loadingTracks: null,
2319
+ lastAccessAt: now,
2320
+ collections: {
2321
+ artists: createSearchBrowseCollectionState(),
2322
+ albums: createSearchBrowseCollectionState(),
2323
+ tracks: createSearchBrowseCollectionState(),
2324
+ },
2325
+ libraryState: {
2326
+ lastCheckedAt: 0,
2327
+ checking: null,
2328
+ lastFingerprint: '',
2329
+ dirty: false,
2330
+ refreshPromise: null,
2331
+ ratingOverrides: new Map(),
2332
+ },
2084
2333
  };
2085
2334
  searchBrowseCache.set(cacheKey, created);
2086
2335
  pruneSearchBrowseCache(now);
2087
2336
  return created;
2088
2337
  }
2089
2338
 
2090
- async function getSearchBrowseCollection({ cacheKey, collection, loader }) {
2091
- const entry = getSearchBrowseCacheEntry(cacheKey);
2092
- if (Array.isArray(entry[collection])) {
2093
- return entry[collection];
2339
+ function getSearchBrowseCollectionState(entry, collection) {
2340
+ if (!entry?.collections || !SEARCH_BROWSE_COLLECTIONS.includes(collection)) {
2341
+ throw new Error(`Invalid cache collection: ${collection}`);
2094
2342
  }
2343
+ return entry.collections[collection];
2344
+ }
2095
2345
 
2096
- const loadingKey = `loading${collection[0].toUpperCase()}${collection.slice(1)}`;
2097
- if (entry[loadingKey]) {
2098
- return entry[loadingKey];
2346
+ async function loadSearchBrowseCollection({ entry, collection, loader, request = null, background = false }) {
2347
+ const state = getSearchBrowseCollectionState(entry, collection);
2348
+ if (state.loading) {
2349
+ return state.loading;
2099
2350
  }
2100
2351
 
2352
+ state.lastRefreshAt = Date.now();
2353
+
2101
2354
  const pending = (async () => {
2102
2355
  const loaded = await loader();
2103
2356
  return Array.isArray(loaded) ? loaded : [];
2104
2357
  })();
2105
- entry[loadingKey] = pending;
2358
+ state.loading = pending;
2106
2359
 
2107
2360
  try {
2108
2361
  const loaded = await pending;
2109
- entry[collection] = loaded;
2110
- entry.expiresAt = Date.now() + SEARCH_BROWSE_CACHE_TTL_MS;
2362
+ applyRatingOverridesToItems(entry, loaded, Date.now());
2363
+ state.data = loaded;
2364
+ state.loadedAt = Date.now();
2111
2365
  return loaded;
2366
+ } catch (error) {
2367
+ if (background) {
2368
+ request?.log?.debug(error, `Background refresh failed for ${collection} cache`);
2369
+ return Array.isArray(state.data) ? state.data : [];
2370
+ }
2371
+ throw error;
2112
2372
  } finally {
2113
- if (entry[loadingKey] === pending) {
2114
- entry[loadingKey] = null;
2373
+ if (state.loading === pending) {
2374
+ state.loading = null;
2375
+ }
2376
+ }
2377
+ }
2378
+
2379
+ async function loadLibraryArtistsRaw({ plexState }) {
2380
+ const loaded = await listArtists({
2381
+ baseUrl: plexState.baseUrl,
2382
+ plexToken: plexState.plexToken,
2383
+ sectionId: plexState.musicSectionId,
2384
+ });
2385
+ return [...loaded].sort((a, b) => String(a?.title || '').localeCompare(String(b?.title || '')));
2386
+ }
2387
+
2388
+ async function loadLibraryAlbumsRaw({ plexState }) {
2389
+ const loaded = await listAlbums({
2390
+ baseUrl: plexState.baseUrl,
2391
+ plexToken: plexState.plexToken,
2392
+ sectionId: plexState.musicSectionId,
2393
+ });
2394
+ return sortAlbumsByName(loaded);
2395
+ }
2396
+
2397
+ async function loadLibraryTracksRaw({ plexState }) {
2398
+ const loaded = await listTracks({
2399
+ baseUrl: plexState.baseUrl,
2400
+ plexToken: plexState.plexToken,
2401
+ sectionId: plexState.musicSectionId,
2402
+ });
2403
+ return sortTracksForLibraryBrowse(loaded);
2404
+ }
2405
+
2406
+ function getLibraryCollectionLoader(collection, plexState) {
2407
+ if (collection === 'artists') {
2408
+ return () => loadLibraryArtistsRaw({ plexState });
2409
+ }
2410
+ if (collection === 'albums') {
2411
+ return () => loadLibraryAlbumsRaw({ plexState });
2412
+ }
2413
+ if (collection === 'tracks') {
2414
+ return () => loadLibraryTracksRaw({ plexState });
2415
+ }
2416
+ throw new Error(`Unsupported library collection: ${collection}`);
2417
+ }
2418
+
2419
+ function markSearchBrowseCacheDirty(cacheKey = null) {
2420
+ const targets = cacheKey
2421
+ ? [[cacheKey, searchBrowseCache.get(cacheKey)]]
2422
+ : [...searchBrowseCache.entries()];
2423
+
2424
+ for (const [, entry] of targets) {
2425
+ if (!entry?.libraryState) {
2426
+ continue;
2427
+ }
2428
+ entry.libraryState.dirty = true;
2429
+ }
2430
+ }
2431
+
2432
+ function normalizeRatingOverride({ userRating = null, clearUserRating = false }, nowMs) {
2433
+ if (clearUserRating) {
2434
+ return {
2435
+ userRating: 0,
2436
+ updatedAtSeconds: Math.floor(nowMs / 1000),
2437
+ expiresAt: nowMs + SEARCH_BROWSE_RATING_OVERRIDE_TTL_MS,
2438
+ };
2439
+ }
2440
+
2441
+ const normalized = normalizePlexRating(userRating);
2442
+ if (normalized == null) {
2443
+ return null;
2444
+ }
2445
+
2446
+ return {
2447
+ userRating: normalized,
2448
+ updatedAtSeconds: Math.floor(nowMs / 1000),
2449
+ expiresAt: nowMs + SEARCH_BROWSE_RATING_OVERRIDE_TTL_MS,
2450
+ };
2451
+ }
2452
+
2453
+ function pruneExpiredRatingOverrides(entry, nowMs) {
2454
+ const overrides = entry?.libraryState?.ratingOverrides;
2455
+ if (!(overrides instanceof Map)) {
2456
+ return;
2457
+ }
2458
+
2459
+ for (const [itemId, override] of overrides.entries()) {
2460
+ if (!override || Number(override.expiresAt || 0) <= nowMs) {
2461
+ overrides.delete(itemId);
2462
+ }
2463
+ }
2464
+ }
2465
+
2466
+ function recordRatingOverrides(entry, itemIds, ratingPatch, nowMs) {
2467
+ const overrides = entry?.libraryState?.ratingOverrides;
2468
+ if (!(overrides instanceof Map)) {
2469
+ return false;
2470
+ }
2471
+
2472
+ const normalized = normalizeRatingOverride(ratingPatch, nowMs);
2473
+ if (!normalized) {
2474
+ return false;
2475
+ }
2476
+
2477
+ let wrote = false;
2478
+ for (const id of itemIds) {
2479
+ const key = String(id || '').trim();
2480
+ if (!key) {
2481
+ continue;
2482
+ }
2483
+ overrides.set(key, normalized);
2484
+ wrote = true;
2485
+ }
2486
+ return wrote;
2487
+ }
2488
+
2489
+ function applyRatingOverridesToItems(entry, items, nowMs = Date.now()) {
2490
+ const overrides = entry?.libraryState?.ratingOverrides;
2491
+ if (!(overrides instanceof Map) || !Array.isArray(items) || items.length === 0) {
2492
+ return 0;
2493
+ }
2494
+
2495
+ pruneExpiredRatingOverrides(entry, nowMs);
2496
+
2497
+ let patched = 0;
2498
+ for (const item of items) {
2499
+ const ratingKey = String(item?.ratingKey || '').trim();
2500
+ if (!ratingKey) {
2501
+ continue;
2502
+ }
2503
+
2504
+ const override = overrides.get(ratingKey);
2505
+ if (!override) {
2506
+ continue;
2507
+ }
2508
+
2509
+ item.userRating = override.userRating;
2510
+ if (Number.isFinite(override.updatedAtSeconds) && override.updatedAtSeconds > 0) {
2511
+ item.updatedAt = override.updatedAtSeconds;
2512
+ }
2513
+ if (!isPlexLiked(item.userRating)) {
2514
+ delete item.starred;
2515
+ delete item.starredAt;
2516
+ }
2517
+ patched += 1;
2518
+ }
2519
+
2520
+ return patched;
2521
+ }
2522
+
2523
+ function applyUserRatingPatchToCacheEntry(entry, itemIds, { userRating = null, clearUserRating = false }, updatedAtSeconds) {
2524
+ if (!entry?.collections) {
2525
+ return 0;
2526
+ }
2527
+
2528
+ const ids = new Set(
2529
+ (Array.isArray(itemIds) ? itemIds : [])
2530
+ .map((id) => String(id || '').trim())
2531
+ .filter(Boolean),
2532
+ );
2533
+ if (ids.size === 0) {
2534
+ return 0;
2535
+ }
2536
+
2537
+ const nowMs = Date.now();
2538
+ recordRatingOverrides(entry, [...ids], { userRating, clearUserRating }, nowMs);
2539
+ pruneExpiredRatingOverrides(entry, nowMs);
2540
+
2541
+ let patchedCount = 0;
2542
+ for (const collection of SEARCH_BROWSE_COLLECTIONS) {
2543
+ const state = entry.collections?.[collection];
2544
+ if (!Array.isArray(state?.data)) {
2545
+ continue;
2546
+ }
2547
+
2548
+ for (const item of state.data) {
2549
+ const ratingKey = String(item?.ratingKey || '').trim();
2550
+ if (!ratingKey || !ids.has(ratingKey)) {
2551
+ continue;
2552
+ }
2553
+ if (clearUserRating) {
2554
+ item.userRating = 0;
2555
+ } else {
2556
+ item.userRating = userRating;
2557
+ }
2558
+ if (!isPlexLiked(item.userRating)) {
2559
+ delete item.starred;
2560
+ delete item.starredAt;
2561
+ }
2562
+ if (Number.isFinite(updatedAtSeconds) && updatedAtSeconds > 0) {
2563
+ item.updatedAt = updatedAtSeconds;
2564
+ }
2565
+ patchedCount += 1;
2566
+ }
2567
+ }
2568
+
2569
+ return patchedCount;
2570
+ }
2571
+
2572
+ function applyUserRatingPatchToSearchBrowseCache({ cacheKey = null, itemIds, userRating = null, clearUserRating = false }) {
2573
+ if (!clearUserRating && normalizePlexRating(userRating) == null) {
2574
+ return 0;
2575
+ }
2576
+ const normalizedRating = clearUserRating ? null : normalizePlexRating(userRating);
2577
+
2578
+ const updatedAtSeconds = Math.floor(Date.now() / 1000);
2579
+ let targets;
2580
+ if (cacheKey) {
2581
+ const ensuredEntry = getSearchBrowseCacheEntry(cacheKey, Date.now());
2582
+ targets = [[cacheKey, ensuredEntry]];
2583
+ } else {
2584
+ targets = [...searchBrowseCache.entries()];
2585
+ }
2586
+
2587
+ let patchedCount = 0;
2588
+ for (const [, entry] of targets) {
2589
+ patchedCount += applyUserRatingPatchToCacheEntry(
2590
+ entry,
2591
+ itemIds,
2592
+ {
2593
+ userRating: normalizedRating,
2594
+ clearUserRating,
2595
+ },
2596
+ updatedAtSeconds,
2597
+ );
2598
+ }
2599
+ return patchedCount;
2600
+ }
2601
+
2602
+ function getCachedUserRatingForItem(entry, itemId) {
2603
+ const key = String(itemId || '').trim();
2604
+ if (!key || !entry) {
2605
+ return null;
2606
+ }
2607
+
2608
+ const nowMs = Date.now();
2609
+ pruneExpiredRatingOverrides(entry, nowMs);
2610
+ const override = entry?.libraryState?.ratingOverrides?.get(key);
2611
+ if (override && Number.isFinite(override.userRating)) {
2612
+ return normalizePlexRatingInt(override.userRating);
2613
+ }
2614
+
2615
+ for (const collection of SEARCH_BROWSE_COLLECTIONS) {
2616
+ const state = entry.collections?.[collection];
2617
+ if (!Array.isArray(state?.data)) {
2618
+ continue;
2619
+ }
2620
+
2621
+ const found = state.data.find((item) => String(item?.ratingKey || '').trim() === key);
2622
+ if (!found) {
2623
+ continue;
2115
2624
  }
2625
+ return normalizePlexRatingInt(found.userRating);
2116
2626
  }
2627
+
2628
+ return null;
2629
+ }
2630
+
2631
+ async function getLibraryFingerprint({ plexState }) {
2632
+ const sectionId = String(plexState.musicSectionId || '');
2633
+ const [sections, probe] = await Promise.all([
2634
+ listMusicSections({
2635
+ baseUrl: plexState.baseUrl,
2636
+ plexToken: plexState.plexToken,
2637
+ }),
2638
+ probeSectionFingerprint({
2639
+ baseUrl: plexState.baseUrl,
2640
+ plexToken: plexState.plexToken,
2641
+ sectionId,
2642
+ }).catch(() => ''),
2643
+ ]);
2644
+
2645
+ const section = sections.find((item) => String(item?.id || '') === sectionId);
2646
+ const sectionPart = section
2647
+ ? `${section.id}|${section.updatedAt}|${section.scannedAt}|${section.refreshedAt}|${section.contentChangedAt}|${section.leafCount}`
2648
+ : `${sectionId}|missing`;
2649
+ return `${sectionPart}|${probe || ''}`;
2650
+ }
2651
+
2652
+ async function refreshSearchBrowseCollectionsForEntry({ entry, plexState, request }) {
2653
+ await Promise.all(
2654
+ SEARCH_BROWSE_COLLECTIONS.map((collection) =>
2655
+ loadSearchBrowseCollection({
2656
+ entry,
2657
+ collection,
2658
+ loader: getLibraryCollectionLoader(collection, plexState),
2659
+ request,
2660
+ background: false,
2661
+ }),
2662
+ ),
2663
+ );
2664
+ entry.libraryState.dirty = false;
2665
+ }
2666
+
2667
+ async function ensureDirtySearchBrowseRefresh({
2668
+ entry,
2669
+ cacheKey,
2670
+ plexState,
2671
+ request,
2672
+ wait = false,
2673
+ }) {
2674
+ if (!entry?.libraryState?.dirty) {
2675
+ return;
2676
+ }
2677
+
2678
+ const libraryState = entry.libraryState;
2679
+ if (!libraryState.refreshPromise) {
2680
+ const pending = (async () => {
2681
+ try {
2682
+ await refreshSearchBrowseCollectionsForEntry({ entry, plexState, request });
2683
+ } catch (error) {
2684
+ request?.log?.warn(error, `Failed to refresh stale cache for ${cacheKey}`);
2685
+ throw error;
2686
+ } finally {
2687
+ if (libraryState.refreshPromise === pending) {
2688
+ libraryState.refreshPromise = null;
2689
+ }
2690
+ }
2691
+ })();
2692
+ libraryState.refreshPromise = pending;
2693
+ }
2694
+
2695
+ if (wait && libraryState.refreshPromise) {
2696
+ try {
2697
+ await libraryState.refreshPromise;
2698
+ } catch {
2699
+ // Keep serving stale cache on refresh failures.
2700
+ }
2701
+ }
2702
+ }
2703
+
2704
+ function maybeCheckLibraryChanges({ entry, cacheKey, plexState, request }) {
2705
+ if (!request || !entry?.libraryState) {
2706
+ return;
2707
+ }
2708
+
2709
+ const libraryState = entry.libraryState;
2710
+ const now = Date.now();
2711
+ if (libraryState.checking) {
2712
+ return;
2713
+ }
2714
+ if ((now - Number(libraryState.lastCheckedAt || 0)) < SEARCH_BROWSE_CHANGE_CHECK_DEBOUNCE_MS) {
2715
+ return;
2716
+ }
2717
+
2718
+ libraryState.lastCheckedAt = now;
2719
+ const pending = (async () => {
2720
+ try {
2721
+ const fingerprint = await getLibraryFingerprint({ plexState });
2722
+ if (!fingerprint) {
2723
+ return;
2724
+ }
2725
+
2726
+ if (!libraryState.lastFingerprint) {
2727
+ libraryState.lastFingerprint = fingerprint;
2728
+ return;
2729
+ }
2730
+
2731
+ if (libraryState.lastFingerprint !== fingerprint) {
2732
+ libraryState.lastFingerprint = fingerprint;
2733
+ libraryState.dirty = true;
2734
+ request.log.info({ cacheKey }, 'Plex library change detected, refreshing cache');
2735
+ await ensureDirtySearchBrowseRefresh({
2736
+ entry,
2737
+ cacheKey,
2738
+ plexState,
2739
+ request,
2740
+ wait: false,
2741
+ });
2742
+ }
2743
+ } catch (error) {
2744
+ request.log.debug(error, `Failed to check library changes for ${cacheKey}`);
2745
+ } finally {
2746
+ if (libraryState.checking === pending) {
2747
+ libraryState.checking = null;
2748
+ }
2749
+ }
2750
+ })();
2751
+ libraryState.checking = pending;
2752
+ }
2753
+
2754
+ function hasCachedLibraryData(entry) {
2755
+ if (!entry?.collections) {
2756
+ return false;
2757
+ }
2758
+ return SEARCH_BROWSE_COLLECTIONS.some((collection) => Array.isArray(entry.collections?.[collection]?.data));
2759
+ }
2760
+
2761
+ function shouldRunLibraryCheckForRequest(request, cacheKey) {
2762
+ if (!request) {
2763
+ return true;
2764
+ }
2765
+
2766
+ const markerKey = '__plexsonicLibraryChecks';
2767
+ let checkedCacheKeys = request[markerKey];
2768
+ if (!(checkedCacheKeys instanceof Set)) {
2769
+ checkedCacheKeys = new Set();
2770
+ request[markerKey] = checkedCacheKeys;
2771
+ }
2772
+
2773
+ if (checkedCacheKeys.has(cacheKey)) {
2774
+ return false;
2775
+ }
2776
+
2777
+ checkedCacheKeys.add(cacheKey);
2778
+ return true;
2779
+ }
2780
+
2781
+ async function getSearchBrowseCollection({ cacheKey, collection, loader, plexState, request = null }) {
2782
+ const now = Date.now();
2783
+ const entry = getSearchBrowseCacheEntry(cacheKey, now);
2784
+ const state = getSearchBrowseCollectionState(entry, collection);
2785
+
2786
+ if (hasCachedLibraryData(entry) && shouldRunLibraryCheckForRequest(request, cacheKey)) {
2787
+ maybeCheckLibraryChanges({ entry, cacheKey, plexState, request });
2788
+ }
2789
+ await ensureDirtySearchBrowseRefresh({
2790
+ entry,
2791
+ cacheKey,
2792
+ plexState,
2793
+ request,
2794
+ wait: true,
2795
+ });
2796
+
2797
+ if (Array.isArray(state.data)) {
2798
+ const isStale = (now - Number(state.loadedAt || 0)) >= SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS;
2799
+ const canRefresh = (now - Number(state.lastRefreshAt || 0)) >= SEARCH_BROWSE_REVALIDATE_DEBOUNCE_MS;
2800
+ if (isStale && canRefresh && !state.loading) {
2801
+ void loadSearchBrowseCollection({
2802
+ entry,
2803
+ collection,
2804
+ loader,
2805
+ request,
2806
+ background: true,
2807
+ });
2808
+ }
2809
+ return state.data;
2810
+ }
2811
+
2812
+ if (state.loading) {
2813
+ return state.loading;
2814
+ }
2815
+
2816
+ return loadSearchBrowseCollection({
2817
+ entry,
2818
+ collection,
2819
+ loader,
2820
+ request,
2821
+ background: false,
2822
+ });
2823
+ }
2824
+
2825
+ async function getCachedLibraryArtists({ accountId, plexState, request }) {
2826
+ const cacheKey = searchBrowseCacheKey(accountId, plexState);
2827
+ return getSearchBrowseCollection({
2828
+ cacheKey,
2829
+ collection: 'artists',
2830
+ plexState,
2831
+ request,
2832
+ loader: () => loadLibraryArtistsRaw({ plexState }),
2833
+ });
2834
+ }
2835
+
2836
+ async function getCachedLibraryAlbums({ accountId, plexState, request }) {
2837
+ const cacheKey = searchBrowseCacheKey(accountId, plexState);
2838
+ return getSearchBrowseCollection({
2839
+ cacheKey,
2840
+ collection: 'albums',
2841
+ plexState,
2842
+ request,
2843
+ loader: () => loadLibraryAlbumsRaw({ plexState }),
2844
+ });
2845
+ }
2846
+
2847
+ async function getCachedLibraryTracks({ accountId, plexState, request }) {
2848
+ const cacheKey = searchBrowseCacheKey(accountId, plexState);
2849
+ return getSearchBrowseCollection({
2850
+ cacheKey,
2851
+ collection: 'tracks',
2852
+ plexState,
2853
+ request,
2854
+ loader: () => loadLibraryTracksRaw({ plexState }),
2855
+ });
2856
+ }
2857
+
2858
+ function applyCachedRatingOverridesForAccount({ accountId, plexState, items }) {
2859
+ if (!Array.isArray(items) || items.length === 0) {
2860
+ return 0;
2861
+ }
2862
+ const cacheKey = searchBrowseCacheKey(accountId, plexState);
2863
+ const entry = searchBrowseCache.get(cacheKey);
2864
+ if (!entry) {
2865
+ return 0;
2866
+ }
2867
+ return applyRatingOverridesToItems(entry, items, Date.now());
2117
2868
  }
2118
2869
 
2119
2870
  function beginSearchRequest(request, accountId) {
@@ -2306,6 +3057,53 @@ export async function buildServer(config = loadConfig()) {
2306
3057
 
2307
3058
  app.get('/health', async () => ({ status: 'ok' }));
2308
3059
 
3060
+ app.post('/webhooks/plex', async (request, reply) => {
3061
+ const expectedToken = String(config.plexWebhookToken || '').trim();
3062
+ const providedToken = String(
3063
+ firstForwardedValue(request.headers?.['x-plexsonic-webhook-token']) ||
3064
+ getQueryFirst(request, 'token') ||
3065
+ getBodyFieldValue(request.body, 'token') ||
3066
+ '',
3067
+ ).trim();
3068
+
3069
+ if (expectedToken && providedToken !== expectedToken) {
3070
+ request.log.warn({ ip: request.ip }, 'Rejected Plex webhook with invalid token');
3071
+ return reply.code(403).send({ ok: false });
3072
+ }
3073
+
3074
+ const payload = parsePlexWebhookPayload(request.body);
3075
+ const event = String(payload?.event || '').trim() || 'unknown';
3076
+ if (isRatingPatchableWebhookEvent(event)) {
3077
+ const ratingPatch = extractRatingPatchFromWebhook(payload);
3078
+ if (ratingPatch) {
3079
+ const patchedCount = applyUserRatingPatchToSearchBrowseCache({
3080
+ itemIds: ratingPatch.itemIds,
3081
+ userRating: ratingPatch.userRating,
3082
+ clearUserRating: ratingPatch.clearUserRating,
3083
+ });
3084
+ if (patchedCount === 0) {
3085
+ markSearchBrowseCacheDirty();
3086
+ }
3087
+ request.log.info({ event, patchedCount }, 'Plex webhook rating event applied to cache');
3088
+ return reply.code(202).send({ ok: true, patched: patchedCount });
3089
+ }
3090
+
3091
+ markSearchBrowseCacheDirty();
3092
+ request.log.info({ event }, 'Plex webhook rating event missing metadata, marked caches dirty');
3093
+ return reply.code(202).send({ ok: true });
3094
+ }
3095
+
3096
+ if (!shouldInvalidateCacheForPlexWebhook(payload)) {
3097
+ request.log.debug({ event }, 'Plex webhook ignored (non-library-changing event)');
3098
+ return reply.code(202).send({ ok: true, ignored: true });
3099
+ }
3100
+
3101
+ markSearchBrowseCacheDirty();
3102
+ request.log.info({ event }, 'Plex webhook received, marked caches dirty');
3103
+
3104
+ return reply.code(202).send({ ok: true });
3105
+ });
3106
+
2309
3107
  app.get('/', async (request, reply) => {
2310
3108
  if (request.session.accountId) {
2311
3109
  return reply.redirect('/link/plex');
@@ -2397,9 +3195,7 @@ export async function buildServer(config = loadConfig()) {
2397
3195
  return reply.code(401).type('text/html; charset=utf-8').send(loginPage('Invalid username or password.'));
2398
3196
  }
2399
3197
 
2400
- if (!account.subsonic_password_enc) {
2401
- repo.updateSubsonicPasswordEnc(account.id, tokenCipher.encrypt(password));
2402
- }
3198
+ syncStoredSubsonicPassword(repo, tokenCipher, account, password);
2403
3199
 
2404
3200
  request.session.accountId = account.id;
2405
3201
  request.session.username = account.username;
@@ -2457,9 +3253,7 @@ export async function buildServer(config = loadConfig()) {
2457
3253
  });
2458
3254
  }
2459
3255
 
2460
- if (!account.subsonic_password_enc) {
2461
- repo.updateSubsonicPasswordEnc(account.id, tokenCipher.encrypt(password));
2462
- }
3256
+ syncStoredSubsonicPassword(repo, tokenCipher, account, password);
2463
3257
 
2464
3258
  request.session.accountId = account.id;
2465
3259
  request.session.username = account.username;
@@ -3092,25 +3886,13 @@ export async function buildServer(config = loadConfig()) {
3092
3886
 
3093
3887
  try {
3094
3888
  const [artists, albums, tracks] = await Promise.all([
3095
- listArtists({
3096
- baseUrl: plexState.baseUrl,
3097
- plexToken: plexState.plexToken,
3098
- sectionId: plexState.musicSectionId,
3099
- }),
3100
- listAlbums({
3101
- baseUrl: plexState.baseUrl,
3102
- plexToken: plexState.plexToken,
3103
- sectionId: plexState.musicSectionId,
3104
- }),
3105
- listTracks({
3106
- baseUrl: plexState.baseUrl,
3107
- plexToken: plexState.plexToken,
3108
- sectionId: plexState.musicSectionId,
3109
- }),
3889
+ getCachedLibraryArtists({ accountId: account.id, plexState, request }),
3890
+ getCachedLibraryAlbums({ accountId: account.id, plexState, request }),
3891
+ getCachedLibraryTracks({ accountId: account.id, plexState, request }),
3110
3892
  ]);
3111
3893
 
3112
3894
  const starredArtists = artists
3113
- .filter((artist) => normalizePlexRating(artist.userRating) >= 9)
3895
+ .filter((artist) => isPlexLiked(artist.userRating))
3114
3896
  .map((artist) =>
3115
3897
  emptyNode('artist', {
3116
3898
  id: artist.ratingKey,
@@ -3122,12 +3904,12 @@ export async function buildServer(config = loadConfig()) {
3122
3904
  .join('');
3123
3905
 
3124
3906
  const starredAlbums = albums
3125
- .filter((album) => normalizePlexRating(album.userRating) >= 9)
3907
+ .filter((album) => isPlexLiked(album.userRating))
3126
3908
  .map((album) => emptyNode('album', albumAttrs(album)))
3127
3909
  .join('');
3128
3910
 
3129
3911
  const starredSongs = tracks
3130
- .filter((track) => normalizePlexRating(track.userRating) >= 9)
3912
+ .filter((track) => isPlexLiked(track.userRating))
3131
3913
  .map((track) => emptyNode('song', songAttrs(track)))
3132
3914
  .join('');
3133
3915
 
@@ -3152,25 +3934,13 @@ export async function buildServer(config = loadConfig()) {
3152
3934
 
3153
3935
  try {
3154
3936
  const [artists, albums, tracks] = await Promise.all([
3155
- listArtists({
3156
- baseUrl: plexState.baseUrl,
3157
- plexToken: plexState.plexToken,
3158
- sectionId: plexState.musicSectionId,
3159
- }),
3160
- listAlbums({
3161
- baseUrl: plexState.baseUrl,
3162
- plexToken: plexState.plexToken,
3163
- sectionId: plexState.musicSectionId,
3164
- }),
3165
- listTracks({
3166
- baseUrl: plexState.baseUrl,
3167
- plexToken: plexState.plexToken,
3168
- sectionId: plexState.musicSectionId,
3169
- }),
3937
+ getCachedLibraryArtists({ accountId: account.id, plexState, request }),
3938
+ getCachedLibraryAlbums({ accountId: account.id, plexState, request }),
3939
+ getCachedLibraryTracks({ accountId: account.id, plexState, request }),
3170
3940
  ]);
3171
3941
 
3172
3942
  const starredArtists = artists
3173
- .filter((artist) => normalizePlexRating(artist.userRating) >= 9)
3943
+ .filter((artist) => isPlexLiked(artist.userRating))
3174
3944
  .map((artist) =>
3175
3945
  emptyNode('artist', {
3176
3946
  id: artist.ratingKey,
@@ -3183,12 +3953,12 @@ export async function buildServer(config = loadConfig()) {
3183
3953
  .join('');
3184
3954
 
3185
3955
  const starredAlbums = albums
3186
- .filter((album) => normalizePlexRating(album.userRating) >= 9)
3956
+ .filter((album) => isPlexLiked(album.userRating))
3187
3957
  .map((album) => emptyNode('album', albumId3Attrs(album)))
3188
3958
  .join('');
3189
3959
 
3190
3960
  const starredSongs = tracks
3191
- .filter((track) => normalizePlexRating(track.userRating) >= 9)
3961
+ .filter((track) => isPlexLiked(track.userRating))
3192
3962
  .map((track) => emptyNode('song', songAttrs(track)))
3193
3963
  .join('');
3194
3964
 
@@ -3215,16 +3985,16 @@ export async function buildServer(config = loadConfig()) {
3215
3985
  }
3216
3986
 
3217
3987
  try {
3218
- const albums = await listAlbums({
3219
- baseUrl: plexState.baseUrl,
3220
- plexToken: plexState.plexToken,
3221
- sectionId: plexState.musicSectionId,
3222
- });
3988
+ const [albums, tracks] = await Promise.all([
3989
+ getCachedLibraryAlbums({ accountId: account.id, plexState, request }),
3990
+ getCachedLibraryTracks({ accountId: account.id, plexState, request }),
3991
+ ]);
3223
3992
 
3993
+ const albumGenreTagMap = buildAlbumGenreTagMap(albums);
3224
3994
  const counts = new Map();
3225
- for (const album of albums) {
3226
- const albumSongCount = Math.max(1, Number.parseInt(String(album?.leafCount ?? ''), 10) || 0);
3227
- for (const tag of allGenreTags(album)) {
3995
+ for (const track of tracks) {
3996
+ const albumId = String(track?.parentRatingKey || '').trim();
3997
+ for (const tag of resolvedGenreTagsForTrack(track, albumGenreTagMap)) {
3228
3998
  const normalized = tag.trim();
3229
3999
  if (!normalized) {
3230
4000
  continue;
@@ -3233,10 +4003,12 @@ export async function buildServer(config = loadConfig()) {
3233
4003
  const current = counts.get(key) || {
3234
4004
  name: normalized,
3235
4005
  songCount: 0,
3236
- albumCount: 0,
4006
+ albumIds: new Set(),
3237
4007
  };
3238
- current.songCount += albumSongCount;
3239
- current.albumCount += 1;
4008
+ current.songCount += 1;
4009
+ if (albumId) {
4010
+ current.albumIds.add(albumId);
4011
+ }
3240
4012
  counts.set(key, current);
3241
4013
  }
3242
4014
  }
@@ -3248,7 +4020,7 @@ export async function buildServer(config = loadConfig()) {
3248
4020
  'genre',
3249
4021
  {
3250
4022
  songCount: genre.songCount,
3251
- albumCount: genre.albumCount,
4023
+ albumCount: genre.albumIds.size,
3252
4024
  },
3253
4025
  genre.name,
3254
4026
  ),
@@ -3287,34 +4059,21 @@ export async function buildServer(config = loadConfig()) {
3287
4059
  }
3288
4060
 
3289
4061
  try {
3290
- const albums = await listAlbums({
3291
- baseUrl: plexState.baseUrl,
3292
- plexToken: plexState.plexToken,
3293
- sectionId: plexState.musicSectionId,
3294
- });
3295
-
3296
- const matchedAlbums = albums.filter((album) =>
3297
- allGenreTags(album).some((tag) => safeLower(tag.trim()) === safeLower(genre)),
4062
+ const [albums, tracks] = await Promise.all([
4063
+ getCachedLibraryAlbums({ accountId: account.id, plexState, request }),
4064
+ getCachedLibraryTracks({ accountId: account.id, plexState, request }),
4065
+ ]);
4066
+
4067
+ const targetGenre = safeLower(genre);
4068
+ const albumGenreTagMap = buildAlbumGenreTagMap(albums);
4069
+ const matchedSongs = tracks.filter((track) =>
4070
+ resolvedGenreTagsForTrack(track, albumGenreTagMap).some(
4071
+ (tag) => safeLower(String(tag || '').trim()) === targetGenre,
4072
+ ),
3298
4073
  );
3299
4074
 
3300
- const songs = [];
3301
- for (const album of matchedAlbums) {
3302
- const tracks = await listAlbumTracks({
3303
- baseUrl: plexState.baseUrl,
3304
- plexToken: plexState.plexToken,
3305
- albumId: album.ratingKey,
3306
- });
3307
-
3308
- for (const track of tracks) {
3309
- songs.push(track);
3310
- }
3311
-
3312
- if (songs.length >= offset + count) {
3313
- break;
3314
- }
3315
- }
3316
-
3317
- const page = takePage(songs, offset, count);
4075
+ const sortedSongs = sortTracksForLibraryBrowse(matchedSongs);
4076
+ const page = takePage(sortedSongs, offset, count);
3318
4077
  const songXml = page.map((track) => emptyNode('song', songAttrs(track))).join('');
3319
4078
  return sendSubsonicOk(reply, node('songsByGenre', {}, songXml));
3320
4079
  } catch (error) {
@@ -3339,11 +4098,7 @@ export async function buildServer(config = loadConfig()) {
3339
4098
  }
3340
4099
 
3341
4100
  try {
3342
- const allTracks = await listTracks({
3343
- baseUrl: plexState.baseUrl,
3344
- plexToken: plexState.plexToken,
3345
- sectionId: plexState.musicSectionId,
3346
- });
4101
+ const allTracks = await getCachedLibraryTracks({ accountId: account.id, plexState, request });
3347
4102
 
3348
4103
  const randomTracks = shuffleInPlace(allTracks.slice()).slice(0, size);
3349
4104
  const songXml = randomTracks.map((track) => emptyNode('song', songAttrs(track))).join('');
@@ -3373,11 +4128,7 @@ export async function buildServer(config = loadConfig()) {
3373
4128
  }
3374
4129
 
3375
4130
  try {
3376
- const artists = await listArtists({
3377
- baseUrl: plexState.baseUrl,
3378
- plexToken: plexState.plexToken,
3379
- sectionId: plexState.musicSectionId,
3380
- });
4131
+ const artists = await getCachedLibraryArtists({ accountId: account.id, plexState, request });
3381
4132
 
3382
4133
  const artist =
3383
4134
  artists.find((item) => safeLower(item.title) === safeLower(artistName)) ||
@@ -3391,6 +4142,7 @@ export async function buildServer(config = loadConfig()) {
3391
4142
  plexToken: plexState.plexToken,
3392
4143
  artistId: artist.ratingKey,
3393
4144
  });
4145
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
3394
4146
 
3395
4147
  const topTracks = tracks.slice(0, size);
3396
4148
  const songXml = topTracks.map((track) => emptyNode('song', songAttrs(track))).join('');
@@ -3512,6 +4264,7 @@ export async function buildServer(config = loadConfig()) {
3512
4264
  if (tracks == null) {
3513
4265
  return sendSubsonicError(reply, 70, 'Item not found');
3514
4266
  }
4267
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
3515
4268
 
3516
4269
  const songXml = tracks.map((track) => emptyNode('song', songAttrs(track))).join('');
3517
4270
  return sendSubsonicOk(reply, node('similarSongs', {}, songXml));
@@ -3555,6 +4308,7 @@ export async function buildServer(config = loadConfig()) {
3555
4308
  if (tracks == null) {
3556
4309
  return sendSubsonicError(reply, 70, 'Item not found');
3557
4310
  }
4311
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
3558
4312
 
3559
4313
  const songXml = tracks.map((track) => emptyNode('song', songAttrs(track))).join('');
3560
4314
  return sendSubsonicOk(reply, node('similarSongs2', {}, songXml));
@@ -3591,6 +4345,7 @@ export async function buildServer(config = loadConfig()) {
3591
4345
  if (!track) {
3592
4346
  return sendSubsonicError(reply, 70, 'Song not found');
3593
4347
  }
4348
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [track] });
3594
4349
 
3595
4350
  return sendSubsonicOk(reply, node('song', songAttrs(track)));
3596
4351
  } catch (error) {
@@ -3844,51 +4599,15 @@ export async function buildServer(config = loadConfig()) {
3844
4599
  let matchedTracks = [];
3845
4600
 
3846
4601
  if (!query) {
3847
- const browseCacheKey = searchBrowseCacheKey(account.id, plexState);
3848
4602
  const [artists, albums, tracks] = await Promise.all([
3849
4603
  artistCount > 0
3850
- ? getSearchBrowseCollection({
3851
- cacheKey: browseCacheKey,
3852
- collection: 'artists',
3853
- loader: async () => {
3854
- const loaded = await listArtists({
3855
- baseUrl: plexState.baseUrl,
3856
- plexToken: plexState.plexToken,
3857
- sectionId: plexState.musicSectionId,
3858
- });
3859
- return [...loaded].sort((a, b) =>
3860
- String(a?.title || '').localeCompare(String(b?.title || '')),
3861
- );
3862
- },
3863
- })
4604
+ ? getCachedLibraryArtists({ accountId: account.id, plexState, request })
3864
4605
  : [],
3865
4606
  albumCount > 0
3866
- ? getSearchBrowseCollection({
3867
- cacheKey: browseCacheKey,
3868
- collection: 'albums',
3869
- loader: async () => {
3870
- const loaded = await listAlbums({
3871
- baseUrl: plexState.baseUrl,
3872
- plexToken: plexState.plexToken,
3873
- sectionId: plexState.musicSectionId,
3874
- });
3875
- return sortAlbumsByName(loaded);
3876
- },
3877
- })
4607
+ ? getCachedLibraryAlbums({ accountId: account.id, plexState, request })
3878
4608
  : [],
3879
4609
  songCount > 0
3880
- ? getSearchBrowseCollection({
3881
- cacheKey: browseCacheKey,
3882
- collection: 'tracks',
3883
- loader: async () => {
3884
- const loaded = await listTracks({
3885
- baseUrl: plexState.baseUrl,
3886
- plexToken: plexState.plexToken,
3887
- sectionId: plexState.musicSectionId,
3888
- });
3889
- return sortTracksForLibraryBrowse(loaded);
3890
- },
3891
- })
4610
+ ? getCachedLibraryTracks({ accountId: account.id, plexState, request })
3892
4611
  : [],
3893
4612
  ]);
3894
4613
 
@@ -4323,14 +5042,24 @@ export async function buildServer(config = loadConfig()) {
4323
5042
  return;
4324
5043
  }
4325
5044
 
5045
+ const cacheKey = searchBrowseCacheKey(account.id, plexState);
5046
+ const cacheEntry = searchBrowseCache.get(cacheKey);
5047
+ const actions = ids.map((id) => {
5048
+ const currentRating = getCachedUserRatingForItem(cacheEntry, id);
5049
+ return {
5050
+ id,
5051
+ targetRating: toLikedPlexRating(currentRating),
5052
+ };
5053
+ });
5054
+
4326
5055
  try {
4327
5056
  await Promise.all(
4328
- ids.map((id) =>
5057
+ actions.map(({ id, targetRating }) =>
4329
5058
  ratePlexItem({
4330
5059
  baseUrl: plexState.baseUrl,
4331
5060
  plexToken: plexState.plexToken,
4332
5061
  itemId: id,
4333
- rating: 10,
5062
+ rating: targetRating,
4334
5063
  }),
4335
5064
  ),
4336
5065
  );
@@ -4339,6 +5068,18 @@ export async function buildServer(config = loadConfig()) {
4339
5068
  return sendSubsonicError(reply, 10, 'Failed to star');
4340
5069
  }
4341
5070
 
5071
+ let patchedCount = 0;
5072
+ for (const { id, targetRating } of actions) {
5073
+ patchedCount += applyUserRatingPatchToSearchBrowseCache({
5074
+ cacheKey,
5075
+ itemIds: [id],
5076
+ userRating: targetRating,
5077
+ });
5078
+ }
5079
+ if (patchedCount === 0) {
5080
+ markSearchBrowseCacheDirty(cacheKey);
5081
+ }
5082
+
4342
5083
  return sendSubsonicOk(reply);
4343
5084
  },
4344
5085
  });
@@ -4368,14 +5109,24 @@ export async function buildServer(config = loadConfig()) {
4368
5109
  return;
4369
5110
  }
4370
5111
 
5112
+ const cacheKey = searchBrowseCacheKey(account.id, plexState);
5113
+ const cacheEntry = searchBrowseCache.get(cacheKey);
5114
+ const actions = ids.map((id) => {
5115
+ const currentRating = getCachedUserRatingForItem(cacheEntry, id);
5116
+ return {
5117
+ id,
5118
+ targetRating: toUnlikedPlexRating(currentRating),
5119
+ };
5120
+ });
5121
+
4371
5122
  try {
4372
5123
  await Promise.all(
4373
- ids.map((id) =>
5124
+ actions.map(({ id, targetRating }) =>
4374
5125
  ratePlexItem({
4375
5126
  baseUrl: plexState.baseUrl,
4376
5127
  plexToken: plexState.plexToken,
4377
5128
  itemId: id,
4378
- rating: 0,
5129
+ rating: targetRating,
4379
5130
  }),
4380
5131
  ),
4381
5132
  );
@@ -4384,6 +5135,18 @@ export async function buildServer(config = loadConfig()) {
4384
5135
  return sendSubsonicError(reply, 10, 'Failed to unstar');
4385
5136
  }
4386
5137
 
5138
+ let patchedCount = 0;
5139
+ for (const { id, targetRating } of actions) {
5140
+ patchedCount += applyUserRatingPatchToSearchBrowseCache({
5141
+ cacheKey,
5142
+ itemIds: [id],
5143
+ userRating: targetRating,
5144
+ });
5145
+ }
5146
+ if (patchedCount === 0) {
5147
+ markSearchBrowseCacheDirty(cacheKey);
5148
+ }
5149
+
4387
5150
  return sendSubsonicOk(reply);
4388
5151
  },
4389
5152
  });
@@ -4415,7 +5178,11 @@ export async function buildServer(config = loadConfig()) {
4415
5178
  return;
4416
5179
  }
4417
5180
 
4418
- const plexRating = Math.round((rating / 5) * 10);
5181
+ const cacheKey = searchBrowseCacheKey(account.id, plexState);
5182
+ const cacheEntry = searchBrowseCache.get(cacheKey);
5183
+ const currentRating = getCachedUserRatingForItem(cacheEntry, id);
5184
+ const preserveLike = rating >= 2 && isPlexLiked(currentRating);
5185
+ const plexRating = subsonicRatingToPlexRating(rating, { liked: preserveLike });
4419
5186
  try {
4420
5187
  await ratePlexItem({
4421
5188
  baseUrl: plexState.baseUrl,
@@ -4428,6 +5195,15 @@ export async function buildServer(config = loadConfig()) {
4428
5195
  return sendSubsonicError(reply, 10, 'Failed to set rating');
4429
5196
  }
4430
5197
 
5198
+ const patchedCount = applyUserRatingPatchToSearchBrowseCache({
5199
+ cacheKey,
5200
+ itemIds: [id],
5201
+ userRating: plexRating,
5202
+ });
5203
+ if (patchedCount === 0) {
5204
+ markSearchBrowseCacheDirty(cacheKey);
5205
+ }
5206
+
4431
5207
  return sendSubsonicOk(reply);
4432
5208
  },
4433
5209
  });
@@ -4793,11 +5569,7 @@ export async function buildServer(config = loadConfig()) {
4793
5569
  }
4794
5570
 
4795
5571
  try {
4796
- const artists = await listArtists({
4797
- baseUrl: plexState.baseUrl,
4798
- plexToken: plexState.plexToken,
4799
- sectionId: plexState.musicSectionId,
4800
- });
5572
+ const artists = await getCachedLibraryArtists({ accountId: account.id, plexState, request });
4801
5573
 
4802
5574
  const indexes = groupArtistsForSubsonic(artists).join('');
4803
5575
  return sendSubsonicOk(reply, node('artists', { ignoredArticles: 'The El La Los Las Le Les' }, indexes));
@@ -4888,12 +5660,14 @@ export async function buildServer(config = loadConfig()) {
4888
5660
  if (!artist) {
4889
5661
  return sendSubsonicError(reply, 70, 'Artist not found');
4890
5662
  }
5663
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
4891
5664
 
4892
5665
  const albums = await listArtistAlbums({
4893
5666
  baseUrl: plexState.baseUrl,
4894
5667
  plexToken: plexState.plexToken,
4895
5668
  artistId,
4896
5669
  });
5670
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
4897
5671
 
4898
5672
  let finalAlbums = albums;
4899
5673
  if (finalAlbums.length === 0) {
@@ -4986,6 +5760,7 @@ export async function buildServer(config = loadConfig()) {
4986
5760
  tracks,
4987
5761
  request,
4988
5762
  });
5763
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [album, ...tracksWithGenre] });
4989
5764
  const sortedTracks = sortTracksByDiscAndIndex(tracksWithGenre);
4990
5765
 
4991
5766
  const totalDuration = sortedTracks.reduce((sum, track) => sum + durationSeconds(track.duration), 0);
@@ -5043,6 +5818,7 @@ export async function buildServer(config = loadConfig()) {
5043
5818
  sectionId: plexState.musicSectionId,
5044
5819
  folderPath: explicitFolderPath,
5045
5820
  });
5821
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: folderResult.items });
5046
5822
 
5047
5823
  const currentFolderPath = isRootFolder ? null : explicitFolderPath;
5048
5824
  const currentDirectoryId = isRootFolder
@@ -5113,11 +5889,13 @@ export async function buildServer(config = loadConfig()) {
5113
5889
  });
5114
5890
 
5115
5891
  if (artist) {
5892
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
5116
5893
  const albums = await listArtistAlbums({
5117
5894
  baseUrl: plexState.baseUrl,
5118
5895
  plexToken: plexState.plexToken,
5119
5896
  artistId: id,
5120
5897
  });
5898
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
5121
5899
 
5122
5900
  let finalAlbums = albums;
5123
5901
  if (finalAlbums.length === 0) {
@@ -5165,6 +5943,7 @@ export async function buildServer(config = loadConfig()) {
5165
5943
  tracks,
5166
5944
  request,
5167
5945
  });
5946
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [album, ...tracksWithGenre] });
5168
5947
  const sortedTracks = sortTracksByDiscAndIndex(tracksWithGenre);
5169
5948
 
5170
5949
  const children = sortedTracks
@@ -5240,11 +6019,7 @@ export async function buildServer(config = loadConfig()) {
5240
6019
  }
5241
6020
 
5242
6021
  try {
5243
- const allAlbums = await listAlbums({
5244
- baseUrl: plexState.baseUrl,
5245
- plexToken: plexState.plexToken,
5246
- sectionId: plexState.musicSectionId,
5247
- });
6022
+ const allAlbums = await getCachedLibraryAlbums({ accountId: account.id, plexState, request });
5248
6023
 
5249
6024
  const filtered = filterAndSortAlbumList(allAlbums, {
5250
6025
  type,