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/.env.example +1 -0
- package/README.md +31 -1
- package/package.json +7 -6
- package/src/config.js +1 -0
- package/src/plex.js +39 -0
- package/src/server.js +956 -181
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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.
|
|
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]?.
|
|
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
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
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
|
-
|
|
2314
|
+
existing.lastAccessAt = now;
|
|
2315
|
+
return existing;
|
|
2074
2316
|
}
|
|
2075
2317
|
|
|
2076
2318
|
const created = {
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
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
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
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
|
-
|
|
2358
|
+
state.loading = pending;
|
|
2106
2359
|
|
|
2107
2360
|
try {
|
|
2108
2361
|
const loaded = await pending;
|
|
2109
|
-
entry
|
|
2110
|
-
|
|
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 (
|
|
2114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
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
|
|
3226
|
-
const
|
|
3227
|
-
for (const tag of
|
|
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
|
-
|
|
4006
|
+
albumIds: new Set(),
|
|
3237
4007
|
};
|
|
3238
|
-
current.songCount +=
|
|
3239
|
-
|
|
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.
|
|
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
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
const
|
|
3297
|
-
|
|
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
|
|
3301
|
-
|
|
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
|
|
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
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
-
|
|
5057
|
+
actions.map(({ id, targetRating }) =>
|
|
4329
5058
|
ratePlexItem({
|
|
4330
5059
|
baseUrl: plexState.baseUrl,
|
|
4331
5060
|
plexToken: plexState.plexToken,
|
|
4332
5061
|
itemId: id,
|
|
4333
|
-
rating:
|
|
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
|
-
|
|
5124
|
+
actions.map(({ id, targetRating }) =>
|
|
4374
5125
|
ratePlexItem({
|
|
4375
5126
|
baseUrl: plexState.baseUrl,
|
|
4376
5127
|
plexToken: plexState.plexToken,
|
|
4377
5128
|
itemId: id,
|
|
4378
|
-
rating:
|
|
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
|
|
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
|
|
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
|
|
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,
|