plexsonic 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/plex.js +15 -0
- package/src/server.js +240 -47
package/package.json
CHANGED
package/src/plex.js
CHANGED
|
@@ -208,6 +208,7 @@ function normalizeLibrarySection(section) {
|
|
|
208
208
|
id: String(section.key ?? section.id ?? ''),
|
|
209
209
|
title: section.title || section.name || 'Untitled',
|
|
210
210
|
type: section.type || '',
|
|
211
|
+
scanning: toBool(section.refreshing) || toBool(section.scanning),
|
|
211
212
|
updatedAt: parseTimestamp(section.updatedAt),
|
|
212
213
|
scannedAt: parseTimestamp(section.scannedAt),
|
|
213
214
|
refreshedAt: parseTimestamp(section.refreshedAt),
|
|
@@ -629,6 +630,20 @@ export async function listMusicSections({ baseUrl, plexToken }) {
|
|
|
629
630
|
.filter((section) => section.type === 'artist' || section.type === 'music');
|
|
630
631
|
}
|
|
631
632
|
|
|
633
|
+
export async function startPlexSectionScan({ baseUrl, plexToken, sectionId, force = true }) {
|
|
634
|
+
await fetchPms(
|
|
635
|
+
baseUrl,
|
|
636
|
+
plexToken,
|
|
637
|
+
`/library/sections/${encodeURIComponent(sectionId)}/refresh`,
|
|
638
|
+
{
|
|
639
|
+
method: 'GET',
|
|
640
|
+
searchParams: {
|
|
641
|
+
force: force ? 1 : 0,
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
632
647
|
export async function listPlexSectionFolder({ baseUrl, plexToken, sectionId, folderPath = null }) {
|
|
633
648
|
const path = folderPath
|
|
634
649
|
? normalizeSectionFolderPath(folderPath, sectionId)
|
package/src/server.js
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
searchSectionHubs,
|
|
70
70
|
searchSectionMetadata,
|
|
71
71
|
scrobblePlexItem,
|
|
72
|
+
startPlexSectionScan,
|
|
72
73
|
updatePlexPlaybackStatus,
|
|
73
74
|
} from './plex.js';
|
|
74
75
|
import { createTokenCipher } from './token-crypto.js';
|
|
@@ -933,6 +934,7 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
|
|
|
933
934
|
[album?.parentTitle, fallbackArtistName],
|
|
934
935
|
'Unknown Artist',
|
|
935
936
|
);
|
|
937
|
+
const genre = firstGenreTag(album) || undefined;
|
|
936
938
|
|
|
937
939
|
return {
|
|
938
940
|
id: albumId,
|
|
@@ -948,6 +950,7 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
|
|
|
948
950
|
duration: durationSeconds(album.duration),
|
|
949
951
|
created: toIsoFromEpochSeconds(album.addedAt),
|
|
950
952
|
year: album.year,
|
|
953
|
+
genre,
|
|
951
954
|
...subsonicRatingAttrs(album),
|
|
952
955
|
};
|
|
953
956
|
}
|
|
@@ -1051,7 +1054,9 @@ function songAttrs(track, albumTitle = null, albumCoverArt = null, albumMetadata
|
|
|
1051
1054
|
[albumCoverArt, track?.parentRatingKey, track?.ratingKey],
|
|
1052
1055
|
undefined,
|
|
1053
1056
|
);
|
|
1054
|
-
const
|
|
1057
|
+
const albumGenreTags = albumMetadata ? allGenreTags(albumMetadata) : [];
|
|
1058
|
+
const genreTagsRaw = allGenreTags(track);
|
|
1059
|
+
const genreTags = genreTagsRaw.length > 0 ? genreTagsRaw : albumGenreTags;
|
|
1055
1060
|
const genre = genreTags[0] || undefined;
|
|
1056
1061
|
const genres = genreTags.length > 0 ? genreTags.join('; ') : undefined;
|
|
1057
1062
|
const discNumber = parsePositiveInt(track?.parentIndex ?? track?.discNumber, 0) || undefined;
|
|
@@ -1689,6 +1694,10 @@ function isAbortError(error) {
|
|
|
1689
1694
|
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
|
|
1690
1695
|
}
|
|
1691
1696
|
|
|
1697
|
+
function isPlexNotFoundError(error) {
|
|
1698
|
+
return String(error?.message || '').includes('(404)');
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1692
1701
|
function safeLower(value) {
|
|
1693
1702
|
return String(value || '').toLowerCase();
|
|
1694
1703
|
}
|
|
@@ -1983,6 +1992,24 @@ function resolvedGenreTagsForTrack(track, albumGenreTagMap) {
|
|
|
1983
1992
|
return Array.isArray(albumTags) ? albumTags : [];
|
|
1984
1993
|
}
|
|
1985
1994
|
|
|
1995
|
+
function withResolvedTrackGenres(track, albumGenreTagMap) {
|
|
1996
|
+
const resolvedTags = resolvedGenreTagsForTrack(track, albumGenreTagMap);
|
|
1997
|
+
if (resolvedTags.length === 0) {
|
|
1998
|
+
return track;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const currentTags = allGenreTags(track);
|
|
2002
|
+
if (currentTags.length > 0) {
|
|
2003
|
+
return track;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
return {
|
|
2007
|
+
...track,
|
|
2008
|
+
Genre: resolvedTags.map((tag) => ({ tag })),
|
|
2009
|
+
genre: resolvedTags.join('; '),
|
|
2010
|
+
};
|
|
2011
|
+
}
|
|
2012
|
+
|
|
1986
2013
|
function firstGenreTag(item) {
|
|
1987
2014
|
const tags = allGenreTags(item);
|
|
1988
2015
|
return tags[0] || null;
|
|
@@ -2395,12 +2422,21 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2395
2422
|
}
|
|
2396
2423
|
|
|
2397
2424
|
async function loadLibraryTracksRaw({ plexState }) {
|
|
2398
|
-
const
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2425
|
+
const [tracks, albums] = await Promise.all([
|
|
2426
|
+
listTracks({
|
|
2427
|
+
baseUrl: plexState.baseUrl,
|
|
2428
|
+
plexToken: plexState.plexToken,
|
|
2429
|
+
sectionId: plexState.musicSectionId,
|
|
2430
|
+
}),
|
|
2431
|
+
listAlbums({
|
|
2432
|
+
baseUrl: plexState.baseUrl,
|
|
2433
|
+
plexToken: plexState.plexToken,
|
|
2434
|
+
sectionId: plexState.musicSectionId,
|
|
2435
|
+
}),
|
|
2436
|
+
]);
|
|
2437
|
+
const albumGenreTagMap = buildAlbumGenreTagMap(albums);
|
|
2438
|
+
const enrichedTracks = tracks.map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
|
|
2439
|
+
return sortTracksForLibraryBrowse(enrichedTracks);
|
|
2404
2440
|
}
|
|
2405
2441
|
|
|
2406
2442
|
function getLibraryCollectionLoader(collection, plexState) {
|
|
@@ -2867,6 +2903,62 @@ export async function buildServer(config = loadConfig()) {
|
|
|
2867
2903
|
return applyRatingOverridesToItems(entry, items, Date.now());
|
|
2868
2904
|
}
|
|
2869
2905
|
|
|
2906
|
+
async function resolveArtistFromCachedLibrary({ accountId, plexState, request, artistId }) {
|
|
2907
|
+
const normalizedArtistId = String(artistId || '').trim();
|
|
2908
|
+
if (!normalizedArtistId) {
|
|
2909
|
+
return null;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
const [artists, albums, tracks] = await Promise.all([
|
|
2913
|
+
getCachedLibraryArtists({ accountId, plexState, request }),
|
|
2914
|
+
getCachedLibraryAlbums({ accountId, plexState, request }),
|
|
2915
|
+
getCachedLibraryTracks({ accountId, plexState, request }),
|
|
2916
|
+
]);
|
|
2917
|
+
|
|
2918
|
+
const cachedArtist = artists.find((item) => String(item?.ratingKey || '').trim() === normalizedArtistId) || null;
|
|
2919
|
+
const cachedAlbums = albums.filter((item) => String(item?.parentRatingKey || '').trim() === normalizedArtistId);
|
|
2920
|
+
const artistTracks = tracks.filter((item) => String(item?.grandparentRatingKey || '').trim() === normalizedArtistId);
|
|
2921
|
+
|
|
2922
|
+
if (!cachedArtist && cachedAlbums.length === 0 && artistTracks.length === 0) {
|
|
2923
|
+
return null;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
const artistName = cachedArtist?.title ||
|
|
2927
|
+
firstNonEmptyText(artistTracks.map((item) => item?.grandparentTitle), `Artist ${normalizedArtistId}`);
|
|
2928
|
+
|
|
2929
|
+
const artist = cachedArtist || {
|
|
2930
|
+
ratingKey: normalizedArtistId,
|
|
2931
|
+
title: artistName,
|
|
2932
|
+
addedAt: artistTracks[0]?.addedAt,
|
|
2933
|
+
updatedAt: artistTracks[0]?.updatedAt,
|
|
2934
|
+
};
|
|
2935
|
+
|
|
2936
|
+
const resolvedAlbums = cachedAlbums.length > 0
|
|
2937
|
+
? cachedAlbums
|
|
2938
|
+
: deriveAlbumsFromTracks(artistTracks, normalizedArtistId, artistName);
|
|
2939
|
+
|
|
2940
|
+
return {
|
|
2941
|
+
artist,
|
|
2942
|
+
albums: resolvedAlbums,
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
function scanStatusAttrsFromSection(section, { fallbackScanning = false } = {}) {
|
|
2947
|
+
return {
|
|
2948
|
+
scanning: Boolean(section?.scanning ?? fallbackScanning),
|
|
2949
|
+
count: parseNonNegativeInt(section?.leafCount, 0),
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
async function getMusicSectionScanStatus(plexState) {
|
|
2954
|
+
const sections = await listMusicSections({
|
|
2955
|
+
baseUrl: plexState.baseUrl,
|
|
2956
|
+
plexToken: plexState.plexToken,
|
|
2957
|
+
});
|
|
2958
|
+
const section = sections.find((item) => String(item?.id || '') === String(plexState.musicSectionId || ''));
|
|
2959
|
+
return section || null;
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2870
2962
|
function beginSearchRequest(request, accountId) {
|
|
2871
2963
|
const clientName =
|
|
2872
2964
|
getRequestParam(request, 'c') ||
|
|
@@ -3848,13 +3940,22 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3848
3940
|
return;
|
|
3849
3941
|
}
|
|
3850
3942
|
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3943
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
3944
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
3945
|
+
if (!plexState) {
|
|
3946
|
+
return;
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
try {
|
|
3950
|
+
const section = await getMusicSectionScanStatus(plexState);
|
|
3951
|
+
return sendSubsonicOk(
|
|
3952
|
+
reply,
|
|
3953
|
+
emptyNode('scanStatus', scanStatusAttrsFromSection(section)),
|
|
3954
|
+
);
|
|
3955
|
+
} catch (error) {
|
|
3956
|
+
request.log.error(error, 'Failed to load scan status from Plex');
|
|
3957
|
+
return sendSubsonicError(reply, 10, 'Failed to load scan status');
|
|
3958
|
+
}
|
|
3858
3959
|
});
|
|
3859
3960
|
|
|
3860
3961
|
app.get('/rest/startScan.view', async (request, reply) => {
|
|
@@ -3863,13 +3964,31 @@ export async function buildServer(config = loadConfig()) {
|
|
|
3863
3964
|
return;
|
|
3864
3965
|
}
|
|
3865
3966
|
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3967
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
3968
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
3969
|
+
if (!plexState) {
|
|
3970
|
+
return;
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
try {
|
|
3974
|
+
await startPlexSectionScan({
|
|
3975
|
+
baseUrl: plexState.baseUrl,
|
|
3976
|
+
plexToken: plexState.plexToken,
|
|
3977
|
+
sectionId: plexState.musicSectionId,
|
|
3978
|
+
force: true,
|
|
3979
|
+
});
|
|
3980
|
+
|
|
3981
|
+
markSearchBrowseCacheDirty(searchBrowseCacheKey(account.id, plexState));
|
|
3982
|
+
|
|
3983
|
+
const section = await getMusicSectionScanStatus(plexState);
|
|
3984
|
+
return sendSubsonicOk(
|
|
3985
|
+
reply,
|
|
3986
|
+
emptyNode('scanStatus', scanStatusAttrsFromSection(section, { fallbackScanning: true })),
|
|
3987
|
+
);
|
|
3988
|
+
} catch (error) {
|
|
3989
|
+
request.log.error(error, 'Failed to trigger Plex scan');
|
|
3990
|
+
return sendSubsonicError(reply, 10, 'Failed to trigger scan');
|
|
3991
|
+
}
|
|
3873
3992
|
});
|
|
3874
3993
|
|
|
3875
3994
|
app.get('/rest/getStarred.view', async (request, reply) => {
|
|
@@ -4073,7 +4192,8 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4073
4192
|
);
|
|
4074
4193
|
|
|
4075
4194
|
const sortedSongs = sortTracksForLibraryBrowse(matchedSongs);
|
|
4076
|
-
const page = takePage(sortedSongs, offset, count)
|
|
4195
|
+
const page = takePage(sortedSongs, offset, count)
|
|
4196
|
+
.map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
|
|
4077
4197
|
const songXml = page.map((track) => emptyNode('song', songAttrs(track))).join('');
|
|
4078
4198
|
return sendSubsonicOk(reply, node('songsByGenre', {}, songXml));
|
|
4079
4199
|
} catch (error) {
|
|
@@ -4337,7 +4457,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4337
4457
|
}
|
|
4338
4458
|
|
|
4339
4459
|
try {
|
|
4340
|
-
|
|
4460
|
+
let track = await getTrack({
|
|
4341
4461
|
baseUrl: plexState.baseUrl,
|
|
4342
4462
|
plexToken: plexState.plexToken,
|
|
4343
4463
|
trackId: id,
|
|
@@ -4345,6 +4465,25 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4345
4465
|
if (!track) {
|
|
4346
4466
|
return sendSubsonicError(reply, 70, 'Song not found');
|
|
4347
4467
|
}
|
|
4468
|
+
|
|
4469
|
+
if (!firstGenreTag(track)) {
|
|
4470
|
+
const albumId = String(track?.parentRatingKey || '').trim();
|
|
4471
|
+
if (albumId) {
|
|
4472
|
+
try {
|
|
4473
|
+
const album = await getAlbum({
|
|
4474
|
+
baseUrl: plexState.baseUrl,
|
|
4475
|
+
plexToken: plexState.plexToken,
|
|
4476
|
+
albumId,
|
|
4477
|
+
});
|
|
4478
|
+
if (album) {
|
|
4479
|
+
const albumGenreTagMap = buildAlbumGenreTagMap([album]);
|
|
4480
|
+
track = withResolvedTrackGenres(track, albumGenreTagMap);
|
|
4481
|
+
}
|
|
4482
|
+
} catch (error) {
|
|
4483
|
+
request.log.debug(error, 'Failed to enrich song genre from album metadata');
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4348
4487
|
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [track] });
|
|
4349
4488
|
|
|
4350
4489
|
return sendSubsonicOk(reply, node('song', songAttrs(track)));
|
|
@@ -5651,32 +5790,51 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5651
5790
|
}
|
|
5652
5791
|
|
|
5653
5792
|
try {
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
plexToken: plexState.plexToken,
|
|
5657
|
-
artistId,
|
|
5658
|
-
});
|
|
5793
|
+
let artist = null;
|
|
5794
|
+
let finalAlbums = [];
|
|
5659
5795
|
|
|
5660
|
-
|
|
5661
|
-
|
|
5796
|
+
try {
|
|
5797
|
+
artist = await getArtist({
|
|
5798
|
+
baseUrl: plexState.baseUrl,
|
|
5799
|
+
plexToken: plexState.plexToken,
|
|
5800
|
+
artistId,
|
|
5801
|
+
});
|
|
5802
|
+
} catch (error) {
|
|
5803
|
+
if (!isPlexNotFoundError(error)) {
|
|
5804
|
+
throw error;
|
|
5805
|
+
}
|
|
5662
5806
|
}
|
|
5663
|
-
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
|
|
5664
5807
|
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
artistId,
|
|
5669
|
-
});
|
|
5670
|
-
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
|
|
5671
|
-
|
|
5672
|
-
let finalAlbums = albums;
|
|
5673
|
-
if (finalAlbums.length === 0) {
|
|
5674
|
-
const tracks = await listArtistTracks({
|
|
5808
|
+
if (artist) {
|
|
5809
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
|
|
5810
|
+
const albums = await listArtistAlbums({
|
|
5675
5811
|
baseUrl: plexState.baseUrl,
|
|
5676
5812
|
plexToken: plexState.plexToken,
|
|
5677
5813
|
artistId,
|
|
5678
5814
|
});
|
|
5679
|
-
|
|
5815
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
|
|
5816
|
+
finalAlbums = albums;
|
|
5817
|
+
if (finalAlbums.length === 0) {
|
|
5818
|
+
const tracks = await listArtistTracks({
|
|
5819
|
+
baseUrl: plexState.baseUrl,
|
|
5820
|
+
plexToken: plexState.plexToken,
|
|
5821
|
+
artistId,
|
|
5822
|
+
});
|
|
5823
|
+
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
|
|
5824
|
+
finalAlbums = deriveAlbumsFromTracks(tracks, artist.ratingKey, artist.title);
|
|
5825
|
+
}
|
|
5826
|
+
} else {
|
|
5827
|
+
const fallback = await resolveArtistFromCachedLibrary({
|
|
5828
|
+
accountId: account.id,
|
|
5829
|
+
plexState,
|
|
5830
|
+
request,
|
|
5831
|
+
artistId,
|
|
5832
|
+
});
|
|
5833
|
+
if (!fallback?.artist) {
|
|
5834
|
+
return sendSubsonicError(reply, 70, 'Artist not found');
|
|
5835
|
+
}
|
|
5836
|
+
artist = fallback.artist;
|
|
5837
|
+
finalAlbums = fallback.albums || [];
|
|
5680
5838
|
}
|
|
5681
5839
|
|
|
5682
5840
|
const albumXml = finalAlbums
|
|
@@ -5882,11 +6040,18 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5882
6040
|
);
|
|
5883
6041
|
}
|
|
5884
6042
|
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
6043
|
+
let artist = null;
|
|
6044
|
+
try {
|
|
6045
|
+
artist = await getArtist({
|
|
6046
|
+
baseUrl: plexState.baseUrl,
|
|
6047
|
+
plexToken: plexState.plexToken,
|
|
6048
|
+
artistId: id,
|
|
6049
|
+
});
|
|
6050
|
+
} catch (error) {
|
|
6051
|
+
if (!isPlexNotFoundError(error)) {
|
|
6052
|
+
throw error;
|
|
6053
|
+
}
|
|
6054
|
+
}
|
|
5890
6055
|
|
|
5891
6056
|
if (artist) {
|
|
5892
6057
|
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
|
|
@@ -5925,6 +6090,34 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5925
6090
|
);
|
|
5926
6091
|
}
|
|
5927
6092
|
|
|
6093
|
+
if (!artist) {
|
|
6094
|
+
const fallback = await resolveArtistFromCachedLibrary({
|
|
6095
|
+
accountId: account.id,
|
|
6096
|
+
plexState,
|
|
6097
|
+
request,
|
|
6098
|
+
artistId: id,
|
|
6099
|
+
});
|
|
6100
|
+
if (fallback?.artist) {
|
|
6101
|
+
const fallbackAlbums = fallback.albums || [];
|
|
6102
|
+
const children = fallbackAlbums
|
|
6103
|
+
.map((album) => emptyNode('child', albumAttrs(album, fallback.artist.ratingKey, fallback.artist.title)))
|
|
6104
|
+
.join('');
|
|
6105
|
+
|
|
6106
|
+
return sendSubsonicOk(
|
|
6107
|
+
reply,
|
|
6108
|
+
node(
|
|
6109
|
+
'directory',
|
|
6110
|
+
{
|
|
6111
|
+
id: fallback.artist.ratingKey,
|
|
6112
|
+
parent: rootDirectoryId,
|
|
6113
|
+
name: fallback.artist.title,
|
|
6114
|
+
},
|
|
6115
|
+
children,
|
|
6116
|
+
),
|
|
6117
|
+
);
|
|
6118
|
+
}
|
|
6119
|
+
}
|
|
6120
|
+
|
|
5928
6121
|
const album = await getAlbum({
|
|
5929
6122
|
baseUrl: plexState.baseUrl,
|
|
5930
6123
|
plexToken: plexState.plexToken,
|