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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/plex.js +15 -0
  3. package/src/server.js +240 -47
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plexsonic",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "PlexMusic to OpenSubsonic bridge",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
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 genreTags = allGenreTags(track);
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 loaded = await listTracks({
2399
- baseUrl: plexState.baseUrl,
2400
- plexToken: plexState.plexToken,
2401
- sectionId: plexState.musicSectionId,
2402
- });
2403
- return sortTracksForLibraryBrowse(loaded);
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
- return sendSubsonicOk(
3852
- reply,
3853
- emptyNode('scanStatus', {
3854
- scanning: false,
3855
- count: 0,
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
- return sendSubsonicOk(
3867
- reply,
3868
- emptyNode('scanStatus', {
3869
- scanning: false,
3870
- count: 0,
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
- const track = await getTrack({
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
- const artist = await getArtist({
5655
- baseUrl: plexState.baseUrl,
5656
- plexToken: plexState.plexToken,
5657
- artistId,
5658
- });
5793
+ let artist = null;
5794
+ let finalAlbums = [];
5659
5795
 
5660
- if (!artist) {
5661
- return sendSubsonicError(reply, 70, 'Artist not found');
5796
+ try {
5797
+ artist = await getArtist({
5798
+ baseUrl: plexState.baseUrl,
5799
+ plexToken: plexState.plexToken,
5800
+ artistId,
5801
+ });
5802
+ } catch (error) {
5803
+ if (!isPlexNotFoundError(error)) {
5804
+ throw error;
5805
+ }
5662
5806
  }
5663
- applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: [artist] });
5664
5807
 
5665
- const albums = await listArtistAlbums({
5666
- baseUrl: plexState.baseUrl,
5667
- plexToken: plexState.plexToken,
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
- finalAlbums = deriveAlbumsFromTracks(tracks, artist.ratingKey, artist.title);
5815
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: albums });
5816
+ finalAlbums = albums;
5817
+ if (finalAlbums.length === 0) {
5818
+ const tracks = await listArtistTracks({
5819
+ baseUrl: plexState.baseUrl,
5820
+ plexToken: plexState.plexToken,
5821
+ artistId,
5822
+ });
5823
+ applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
5824
+ finalAlbums = deriveAlbumsFromTracks(tracks, artist.ratingKey, artist.title);
5825
+ }
5826
+ } else {
5827
+ const fallback = await resolveArtistFromCachedLibrary({
5828
+ accountId: account.id,
5829
+ plexState,
5830
+ request,
5831
+ artistId,
5832
+ });
5833
+ if (!fallback?.artist) {
5834
+ return sendSubsonicError(reply, 70, 'Artist not found');
5835
+ }
5836
+ artist = fallback.artist;
5837
+ finalAlbums = fallback.albums || [];
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
- const artist = await getArtist({
5886
- baseUrl: plexState.baseUrl,
5887
- plexToken: plexState.plexToken,
5888
- artistId: id,
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,