plexsonic 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -10,6 +10,7 @@ TOKEN_ENC_KEY=
10
10
  PLEX_PRODUCT=Plexsonic Bridge
11
11
  # Stable client identifier recommended for Plex PIN flow.
12
12
  PLEX_CLIENT_IDENTIFIER=
13
+ PLEX_WEBHOOK_TOKEN=
13
14
  LICENSE_EMAIL=
14
15
  PLEX_INSECURE_TLS=0
15
16
  LOG_LEVEL=warn
package/README.md CHANGED
@@ -57,6 +57,7 @@ SESSION_SECRET=replace-with-a-long-random-secret
57
57
  TOKEN_ENC_KEY=
58
58
  PLEX_PRODUCT=Plexsonic Bridge
59
59
  PLEX_CLIENT_IDENTIFIER=
60
+ PLEX_WEBHOOK_TOKEN=
60
61
  LICENSE_EMAIL=
61
62
  PLEX_INSECURE_TLS=0
62
63
  LOG_LEVEL=warn
@@ -68,10 +69,11 @@ LOG_REQUESTS=0
68
69
  - `PORT`: HTTP port (default `3127`).
69
70
  - `BIND_HOST`: listen interface (`127.0.0.1` local only, `0.0.0.0` for LAN).
70
71
  - `BASE_URL`: optional public URL override used for callback generation. If empty, origin is derived from request headers.
72
+ - `PLEX_WEBHOOK_TOKEN`: optional shared secret for `/webhooks/plex`. If set, webhook calls must provide this token.
71
73
  - `SESSION_SECRET`: cookie/session signing secret. Keep stable across restarts.
72
74
  - `TOKEN_ENC_KEY`: optional but recommended 32-byte key (hex or base64) used to encrypt stored Plex tokens.
73
75
  - `LOG_LEVEL`: logger level (`trace`, `debug`, `info`, `warn`, `error`, `fatal`).
74
- - `LOG_REQUESTS`: set to `1` to enable incoming request logs. Very verbose, and can expose your login credeitial. (`0` by default).
76
+ - `LOG_REQUESTS`: set to `1` to enable incoming request logs. Very verbose, and can expose login credentials. (`0` by default).
75
77
 
76
78
  Generate secrets (examples):
77
79
 
@@ -166,6 +168,18 @@ Both endpoint styles are accepted:
166
168
  - `/rest/getArtists.view`
167
169
  - `/rest/getArtists`
168
170
 
171
+ ### Star/Like Mapping (Plex)
172
+
173
+ Plexsonic maps Subsonic rating + star state into a single Plex numeric rating:
174
+ - Odd points = rated only (not liked): `1, 3, 5, 7, 9`
175
+ - Even points = liked: `2, 4, 6, 8, 10`
176
+ - `0` = no rating and not liked
177
+
178
+ Behavior:
179
+ - `setRating(r)` updates stars and keeps current like state when possible.
180
+ - `star` toggles like on and keeps star level. If unrated, it becomes `2` points (liked + 1-star).
181
+ - `unstar` toggles like off and keeps star level.
182
+
169
183
  ## Expose to LAN
170
184
 
171
185
  Set:
@@ -184,6 +198,22 @@ If you run behind a reverse proxy, forward `X-Forwarded-Proto` and `X-Forwarded-
184
198
 
185
199
  Without HTTPS, credentials travel unencrypted on your network.
186
200
 
201
+ ## Plex Webhooks (Optional, Recommended)
202
+
203
+ Webhook purpose: Plex notifies Plexsonic on library/media events so Plexsonic can refresh caches faster and reduce stale results.
204
+
205
+ Plex does not auto-discover Plexsonic. You must add the webhook URL in Plex Media Server settings.
206
+
207
+ 1. Open Plex Media Server settings, then `Network` -> `Webhooks`.
208
+ 2. Add your Plexsonic endpoint URL.
209
+ Without token: `http://<your-host>:3127/webhooks/plex`
210
+ With token: `http://<your-host>:3127/webhooks/plex?token=<PLEX_WEBHOOK_TOKEN>`
211
+ 3. Save settings in Plex.
212
+
213
+ Notes:
214
+ - `BASE_URL` is not required for webhook processing.
215
+ - Webhook URL must be reachable by your Plex Media Server (LAN IP/hostname or public URL, depending on your setup).
216
+
187
217
  ## Notes
188
218
 
189
219
  - This project currently targets practical client compatibility over strict parity with any single server implementation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plexsonic",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "PlexMusic to OpenSubsonic bridge",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
@@ -21,11 +21,12 @@
21
21
  "dependencies": {
22
22
  "@fastify/cookie": "^11.0.2",
23
23
  "@fastify/formbody": "^8.0.2",
24
- "@fastify/session": "^11.1.0",
25
- "argon2": "^0.41.1",
26
- "better-sqlite3": "^11.7.2",
27
- "dotenv": "^16.6.1",
28
- "fastify": "^5.1.0",
24
+ "@fastify/multipart": "^9.4.0",
25
+ "@fastify/session": "^11.1.1",
26
+ "argon2": "^0.44.0",
27
+ "better-sqlite3": "^12.6.2",
28
+ "dotenv": "^17.2.4",
29
+ "fastify": "^5.7.4",
29
30
  "pino-pretty": "^13.1.3"
30
31
  },
31
32
  "pnpm": {
package/src/config.js CHANGED
@@ -89,6 +89,7 @@ export function loadConfig(env = process.env) {
89
89
  plexProduct: env.PLEX_PRODUCT || DEFAULT_PLEX_PRODUCT,
90
90
  plexClientIdentifier:
91
91
  env.PLEX_CLIENT_IDENTIFIER || deriveClientIdentifier(path.resolve(sqlitePath)),
92
+ plexWebhookToken: env.PLEX_WEBHOOK_TOKEN || '',
92
93
  licenseEmail: env.LICENSE_EMAIL || '',
93
94
  logLevel: env.LOG_LEVEL || DEFAULT_LOG_LEVEL,
94
95
  logRequests: parseBoolean(env.LOG_REQUESTS, DEFAULT_LOG_REQUESTS),
package/src/plex.js CHANGED
@@ -199,10 +199,21 @@ function extractMetadataList(container) {
199
199
  }
200
200
 
201
201
  function normalizeLibrarySection(section) {
202
+ const parseTimestamp = (value) => {
203
+ const parsed = Number.parseInt(String(value ?? ''), 10);
204
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
205
+ };
206
+
202
207
  return {
203
208
  id: String(section.key ?? section.id ?? ''),
204
209
  title: section.title || section.name || 'Untitled',
205
210
  type: section.type || '',
211
+ scanning: toBool(section.refreshing) || toBool(section.scanning),
212
+ updatedAt: parseTimestamp(section.updatedAt),
213
+ scannedAt: parseTimestamp(section.scannedAt),
214
+ refreshedAt: parseTimestamp(section.refreshedAt),
215
+ contentChangedAt: parseTimestamp(section.contentChangedAt),
216
+ leafCount: parseTimestamp(section.leafCount),
206
217
  };
207
218
  }
208
219
 
@@ -619,6 +630,20 @@ export async function listMusicSections({ baseUrl, plexToken }) {
619
630
  .filter((section) => section.type === 'artist' || section.type === 'music');
620
631
  }
621
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
+
622
647
  export async function listPlexSectionFolder({ baseUrl, plexToken, sectionId, folderPath = null }) {
623
648
  const path = folderPath
624
649
  ? normalizeSectionFolderPath(folderPath, sectionId)
@@ -804,6 +829,35 @@ export async function listTracks({ baseUrl, plexToken, sectionId }) {
804
829
  return extractMetadataList(payload.MediaContainer);
805
830
  }
806
831
 
832
+ export async function probeSectionFingerprint({ baseUrl, plexToken, sectionId, signal = undefined }) {
833
+ const payload = await fetchPmsJson(
834
+ baseUrl,
835
+ plexToken,
836
+ `/library/sections/${encodeURIComponent(sectionId)}/all`,
837
+ {
838
+ type: 10,
839
+ sort: 'updatedAt:desc',
840
+ 'X-Plex-Container-Start': 0,
841
+ 'X-Plex-Container-Size': 1,
842
+ },
843
+ { signal },
844
+ );
845
+
846
+ const container = payload?.MediaContainer || {};
847
+ const firstTrack = extractMetadataList(container)[0] || {};
848
+ const parseIntSafe = (value) => {
849
+ const parsed = Number.parseInt(String(value ?? ''), 10);
850
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
851
+ };
852
+
853
+ const totalSize = parseIntSafe(container.totalSize);
854
+ const ratingKey = String(firstTrack.ratingKey || '');
855
+ const updatedAt = parseIntSafe(firstTrack.updatedAt);
856
+ const addedAt = parseIntSafe(firstTrack.addedAt);
857
+
858
+ return `${sectionId}|${totalSize}|${ratingKey}|${updatedAt}|${addedAt}`;
859
+ }
860
+
807
861
  export async function getArtist({ baseUrl, plexToken, artistId }) {
808
862
  const payload = await fetchPmsJson(baseUrl, plexToken, `/library/metadata/${encodeURIComponent(artistId)}`);
809
863
  const item = extractMetadataList(payload.MediaContainer)[0] || null;