plexsonic 0.1.12 → 0.1.20
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 +4 -0
- package/README.md +26 -0
- package/package.json +1 -1
- package/src/config.js +27 -0
- package/src/db.js +236 -0
- package/src/plex.js +119 -24
- package/src/server.js +6496 -2509
- package/src/subsonic-xml.js +234 -261
package/.env.example
CHANGED
|
@@ -4,6 +4,10 @@ BIND_HOST=127.0.0.1
|
|
|
4
4
|
# Leave empty to derive from incoming request headers.
|
|
5
5
|
BASE_URL=
|
|
6
6
|
SQLITE_PATH=./data/app.db
|
|
7
|
+
CACHE_SQLITE_PATH=./data/cache.db
|
|
8
|
+
TRANSCODE_CACHE_PATH=./data/transcodes
|
|
9
|
+
TRANSCODE_CLEANUP_INTERVAL_SEC=3600
|
|
10
|
+
TRANSCODE_ARTIFACT_MAX_AGE_SEC=604800
|
|
7
11
|
SESSION_SECRET=change-this-session-secret-before-production
|
|
8
12
|
# 32-byte key in hex or base64 for Plex token encryption.
|
|
9
13
|
TOKEN_ENC_KEY=
|
package/README.md
CHANGED
|
@@ -10,6 +10,12 @@ It provides:
|
|
|
10
10
|
- Web test page for manual API checks
|
|
11
11
|
- Playback/scrobble/rating/playlist actions mapped to Plex
|
|
12
12
|
|
|
13
|
+
## Local Cache
|
|
14
|
+
|
|
15
|
+
Plexsonic keeps a local SQLite cache of Plex music metadata to make browse/search responses fast and reduce repeated Plex API calls.
|
|
16
|
+
The cache is stored separately from account/session data (`CACHE_SQLITE_PATH`, default `./data/cache.db`), refreshes automatically when Plex changes are detected, and can be safely rebuilt if removed.
|
|
17
|
+
Cache entries are scoped by Plex account + Plex server/library, so local Plexsonic users linked to the same Plex account can share cached metadata.
|
|
18
|
+
|
|
13
19
|
To support this project, please subscribe to my [Patreon](https://www.patreon.com/ClassicOldSong).
|
|
14
20
|
|
|
15
21
|
[](https://patreon.com/ClassicOldsong)
|
|
@@ -53,6 +59,10 @@ PORT=3127
|
|
|
53
59
|
BIND_HOST=127.0.0.1
|
|
54
60
|
BASE_URL=
|
|
55
61
|
SQLITE_PATH=./data/app.db
|
|
62
|
+
CACHE_SQLITE_PATH=./data/cache.db
|
|
63
|
+
TRANSCODE_CACHE_PATH=./data/transcodes
|
|
64
|
+
TRANSCODE_CLEANUP_INTERVAL_SEC=3600
|
|
65
|
+
TRANSCODE_ARTIFACT_MAX_AGE_SEC=604800
|
|
56
66
|
SESSION_SECRET=replace-with-a-long-random-secret
|
|
57
67
|
TOKEN_ENC_KEY=
|
|
58
68
|
PLEX_PRODUCT=Plexsonic Bridge
|
|
@@ -69,6 +79,11 @@ LOG_REQUESTS=0
|
|
|
69
79
|
- `PORT`: HTTP port (default `3127`).
|
|
70
80
|
- `BIND_HOST`: listen interface (`127.0.0.1` local only, `0.0.0.0` for LAN).
|
|
71
81
|
- `BASE_URL`: optional public URL override used for callback generation. If empty, origin is derived from request headers.
|
|
82
|
+
- `SQLITE_PATH`: credentials/session/application database path.
|
|
83
|
+
- `CACHE_SQLITE_PATH`: WAL-backed Plex metadata cache database path.
|
|
84
|
+
- `TRANSCODE_CACHE_PATH`: local cache directory for ffmpeg-transcoded stream outputs.
|
|
85
|
+
- `TRANSCODE_CLEANUP_INTERVAL_SEC`: periodic cleanup interval for transcode artifacts (`0` disables cleanup).
|
|
86
|
+
- `TRANSCODE_ARTIFACT_MAX_AGE_SEC`: max artifact age before cleanup removes it (`0` disables cleanup).
|
|
72
87
|
- `PLEX_WEBHOOK_TOKEN`: optional shared secret for `/webhooks/plex`. If set, webhook calls must provide this token.
|
|
73
88
|
- `SESSION_SECRET`: cookie/session signing secret. Keep stable across restarts.
|
|
74
89
|
- `TOKEN_ENC_KEY`: optional but recommended 32-byte key (hex or base64) used to encrypt stored Plex tokens.
|
|
@@ -85,6 +100,17 @@ openssl rand -hex 32
|
|
|
85
100
|
openssl rand -hex 32
|
|
86
101
|
```
|
|
87
102
|
|
|
103
|
+
## Streaming Transcode
|
|
104
|
+
|
|
105
|
+
`/rest/stream.view` can transcode in-bridge (ffmpeg) to `mp3`, `aac`, `opus`, and `flac`.
|
|
106
|
+
|
|
107
|
+
- `format` chooses target codec/container when supported (`mp3`, `aac`, `opus`, `flac`).
|
|
108
|
+
- `maxBitRate` is respected for lossy outputs.
|
|
109
|
+
- If only `maxBitRate` is provided, Plexsonic defaults to `opus` transcode.
|
|
110
|
+
- `estimateContentLength` adds a best-effort `Content-Length` for uncached transcodes.
|
|
111
|
+
- Transcode outputs are cached on disk (`TRANSCODE_CACHE_PATH`) to enable byte-range seeking on subsequent requests.
|
|
112
|
+
- Cached transcode artifacts are auto-pruned by age (`TRANSCODE_CLEANUP_INTERVAL_SEC` / `TRANSCODE_ARTIFACT_MAX_AGE_SEC`).
|
|
113
|
+
|
|
88
114
|
## Run
|
|
89
115
|
|
|
90
116
|
```bash
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -28,6 +28,8 @@ const DEFAULT_PLEX_PRODUCT = 'Plexsonic Bridge';
|
|
|
28
28
|
const DEFAULT_SESSION_SECRET = 'dev-session-secret-change-me-before-production-plexsonic';
|
|
29
29
|
const DEFAULT_LOG_LEVEL = 'warn';
|
|
30
30
|
const DEFAULT_LOG_REQUESTS = false;
|
|
31
|
+
const DEFAULT_TRANSCODE_CLEANUP_INTERVAL_SECONDS = 3600;
|
|
32
|
+
const DEFAULT_TRANSCODE_ARTIFACT_MAX_AGE_SECONDS = 604800;
|
|
31
33
|
|
|
32
34
|
function parsePort(rawPort) {
|
|
33
35
|
const value = Number.parseInt(rawPort ?? `${DEFAULT_PORT}`, 10);
|
|
@@ -57,6 +59,17 @@ function parseBoolean(value, fallback = false) {
|
|
|
57
59
|
return ['1', 'true', 'yes', 'on'].includes(normalized);
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
function parseNonNegativeInt(value, fallback) {
|
|
63
|
+
if (value == null || value === '') {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
67
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
function normalizeOptionalBaseUrl(rawBaseUrl) {
|
|
61
74
|
const value = String(rawBaseUrl || '').trim();
|
|
62
75
|
if (!value) {
|
|
@@ -77,12 +90,26 @@ export function loadConfig(env = process.env) {
|
|
|
77
90
|
const bindHost = env.BIND_HOST || DEFAULT_HOST;
|
|
78
91
|
const baseUrl = normalizeOptionalBaseUrl(env.BASE_URL);
|
|
79
92
|
const sqlitePath = env.SQLITE_PATH || './data/app.db';
|
|
93
|
+
const cacheSqlitePath = env.CACHE_SQLITE_PATH || './data/cache.db';
|
|
94
|
+
const transcodeCachePath = env.TRANSCODE_CACHE_PATH || './data/transcodes';
|
|
95
|
+
const transcodeCleanupIntervalSeconds = parseNonNegativeInt(
|
|
96
|
+
env.TRANSCODE_CLEANUP_INTERVAL_SEC,
|
|
97
|
+
DEFAULT_TRANSCODE_CLEANUP_INTERVAL_SECONDS,
|
|
98
|
+
);
|
|
99
|
+
const transcodeArtifactMaxAgeSeconds = parseNonNegativeInt(
|
|
100
|
+
env.TRANSCODE_ARTIFACT_MAX_AGE_SEC,
|
|
101
|
+
DEFAULT_TRANSCODE_ARTIFACT_MAX_AGE_SECONDS,
|
|
102
|
+
);
|
|
80
103
|
|
|
81
104
|
return {
|
|
82
105
|
bindHost,
|
|
83
106
|
baseUrl,
|
|
84
107
|
port,
|
|
85
108
|
sqlitePath,
|
|
109
|
+
cacheSqlitePath,
|
|
110
|
+
transcodeCachePath,
|
|
111
|
+
transcodeCleanupIntervalSeconds,
|
|
112
|
+
transcodeArtifactMaxAgeSeconds,
|
|
86
113
|
sessionSecret: normalizeSessionSecret(env.SESSION_SECRET),
|
|
87
114
|
tokenEncKey: env.TOKEN_ENC_KEY || null,
|
|
88
115
|
plexInsecureTls: env.PLEX_INSECURE_TLS === '1',
|
package/src/db.js
CHANGED
|
@@ -121,6 +121,221 @@ export function migrate(db) {
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
export function migrateCache(db) {
|
|
125
|
+
const hasCacheAlbumTable = Boolean(
|
|
126
|
+
db.prepare(`
|
|
127
|
+
SELECT 1 AS exists_flag
|
|
128
|
+
FROM sqlite_master
|
|
129
|
+
WHERE type = 'table' AND name = 'plex_library_cache_albums'
|
|
130
|
+
LIMIT 1
|
|
131
|
+
`).get(),
|
|
132
|
+
);
|
|
133
|
+
if (hasCacheAlbumTable) {
|
|
134
|
+
const albumColumns = db.prepare(`PRAGMA table_info(plex_library_cache_albums)`).all();
|
|
135
|
+
const hasPlayCountColumn = albumColumns.some((column) => column.name === 'play_count');
|
|
136
|
+
if (!hasPlayCountColumn) {
|
|
137
|
+
db.exec(`
|
|
138
|
+
DROP TABLE IF EXISTS plex_library_cache_track_album_artists;
|
|
139
|
+
DROP TABLE IF EXISTS plex_library_cache_track_artists;
|
|
140
|
+
DROP TABLE IF EXISTS plex_library_cache_track_genres;
|
|
141
|
+
DROP TABLE IF EXISTS plex_library_cache_tracks;
|
|
142
|
+
DROP TABLE IF EXISTS plex_library_cache_album_genres;
|
|
143
|
+
DROP TABLE IF EXISTS plex_library_cache_albums;
|
|
144
|
+
DROP TABLE IF EXISTS plex_library_cache_artists;
|
|
145
|
+
DROP TABLE IF EXISTS plex_library_cache_state;
|
|
146
|
+
`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
db.exec(`
|
|
151
|
+
CREATE TABLE IF NOT EXISTS plex_library_cache_state (
|
|
152
|
+
cache_key TEXT PRIMARY KEY,
|
|
153
|
+
last_fingerprint TEXT NOT NULL DEFAULT '',
|
|
154
|
+
last_checked_at INTEGER NOT NULL DEFAULT 0,
|
|
155
|
+
last_synced_at INTEGER NOT NULL DEFAULT 0,
|
|
156
|
+
dirty INTEGER NOT NULL DEFAULT 0,
|
|
157
|
+
updated_at INTEGER NOT NULL
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
CREATE TABLE IF NOT EXISTS plex_library_cache_artists (
|
|
161
|
+
cache_key TEXT NOT NULL,
|
|
162
|
+
rating_key TEXT NOT NULL,
|
|
163
|
+
order_index INTEGER NOT NULL DEFAULT 0,
|
|
164
|
+
sort_key TEXT NOT NULL DEFAULT '',
|
|
165
|
+
title TEXT,
|
|
166
|
+
title_sort TEXT,
|
|
167
|
+
summary TEXT,
|
|
168
|
+
thumb TEXT,
|
|
169
|
+
key_path TEXT,
|
|
170
|
+
guid TEXT,
|
|
171
|
+
source_uri TEXT,
|
|
172
|
+
added_at INTEGER,
|
|
173
|
+
updated_at INTEGER,
|
|
174
|
+
user_rating INTEGER,
|
|
175
|
+
child_count INTEGER,
|
|
176
|
+
leaf_count INTEGER,
|
|
177
|
+
album_count INTEGER,
|
|
178
|
+
type TEXT,
|
|
179
|
+
updated_cache_at INTEGER NOT NULL,
|
|
180
|
+
PRIMARY KEY (cache_key, rating_key)
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
CREATE TABLE IF NOT EXISTS plex_library_cache_albums (
|
|
184
|
+
cache_key TEXT NOT NULL,
|
|
185
|
+
rating_key TEXT NOT NULL,
|
|
186
|
+
order_index INTEGER NOT NULL DEFAULT 0,
|
|
187
|
+
sort_key TEXT NOT NULL DEFAULT '',
|
|
188
|
+
title TEXT,
|
|
189
|
+
title_sort TEXT,
|
|
190
|
+
original_title TEXT,
|
|
191
|
+
parent_rating_key TEXT,
|
|
192
|
+
parent_title TEXT,
|
|
193
|
+
thumb TEXT,
|
|
194
|
+
key_path TEXT,
|
|
195
|
+
guid TEXT,
|
|
196
|
+
source_uri TEXT,
|
|
197
|
+
added_at INTEGER,
|
|
198
|
+
updated_at INTEGER,
|
|
199
|
+
last_viewed_at INTEGER,
|
|
200
|
+
user_rating INTEGER,
|
|
201
|
+
play_count INTEGER,
|
|
202
|
+
child_count INTEGER,
|
|
203
|
+
leaf_count INTEGER,
|
|
204
|
+
duration INTEGER,
|
|
205
|
+
year INTEGER,
|
|
206
|
+
type TEXT,
|
|
207
|
+
updated_cache_at INTEGER NOT NULL,
|
|
208
|
+
PRIMARY KEY (cache_key, rating_key)
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
CREATE TABLE IF NOT EXISTS plex_library_cache_album_genres (
|
|
212
|
+
cache_key TEXT NOT NULL,
|
|
213
|
+
album_rating_key TEXT NOT NULL,
|
|
214
|
+
order_index INTEGER NOT NULL DEFAULT 0,
|
|
215
|
+
genre_name TEXT NOT NULL,
|
|
216
|
+
PRIMARY KEY (cache_key, album_rating_key, genre_name)
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
CREATE TABLE IF NOT EXISTS plex_library_cache_tracks (
|
|
220
|
+
cache_key TEXT NOT NULL,
|
|
221
|
+
rating_key TEXT NOT NULL,
|
|
222
|
+
order_index INTEGER NOT NULL DEFAULT 0,
|
|
223
|
+
sort_key TEXT NOT NULL DEFAULT '',
|
|
224
|
+
title TEXT,
|
|
225
|
+
title_sort TEXT,
|
|
226
|
+
original_title TEXT,
|
|
227
|
+
parent_rating_key TEXT,
|
|
228
|
+
parent_title TEXT,
|
|
229
|
+
grandparent_rating_key TEXT,
|
|
230
|
+
grandparent_title TEXT,
|
|
231
|
+
artist_id TEXT,
|
|
232
|
+
key_path TEXT,
|
|
233
|
+
guid TEXT,
|
|
234
|
+
source_uri TEXT,
|
|
235
|
+
thumb TEXT,
|
|
236
|
+
added_at INTEGER,
|
|
237
|
+
updated_at INTEGER,
|
|
238
|
+
last_viewed_at INTEGER,
|
|
239
|
+
user_rating INTEGER,
|
|
240
|
+
view_count INTEGER,
|
|
241
|
+
duration INTEGER,
|
|
242
|
+
track_index INTEGER,
|
|
243
|
+
parent_index INTEGER,
|
|
244
|
+
disc_number INTEGER,
|
|
245
|
+
parent_year INTEGER,
|
|
246
|
+
year INTEGER,
|
|
247
|
+
media_bitrate INTEGER,
|
|
248
|
+
media_container TEXT,
|
|
249
|
+
part_size INTEGER,
|
|
250
|
+
part_file TEXT,
|
|
251
|
+
audio_sampling_rate INTEGER,
|
|
252
|
+
audio_bit_depth INTEGER,
|
|
253
|
+
audio_stream_language TEXT,
|
|
254
|
+
composer TEXT,
|
|
255
|
+
country TEXT,
|
|
256
|
+
style TEXT,
|
|
257
|
+
mood TEXT,
|
|
258
|
+
record_label TEXT,
|
|
259
|
+
language TEXT,
|
|
260
|
+
album_type TEXT,
|
|
261
|
+
is_compilation INTEGER,
|
|
262
|
+
is_soundtrack INTEGER,
|
|
263
|
+
type TEXT,
|
|
264
|
+
updated_cache_at INTEGER NOT NULL,
|
|
265
|
+
PRIMARY KEY (cache_key, rating_key)
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
CREATE TABLE IF NOT EXISTS plex_library_cache_track_genres (
|
|
269
|
+
cache_key TEXT NOT NULL,
|
|
270
|
+
track_rating_key TEXT NOT NULL,
|
|
271
|
+
order_index INTEGER NOT NULL DEFAULT 0,
|
|
272
|
+
genre_name TEXT NOT NULL,
|
|
273
|
+
PRIMARY KEY (cache_key, track_rating_key, genre_name)
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
CREATE TABLE IF NOT EXISTS plex_library_cache_track_artists (
|
|
277
|
+
cache_key TEXT NOT NULL,
|
|
278
|
+
track_rating_key TEXT NOT NULL,
|
|
279
|
+
order_index INTEGER NOT NULL DEFAULT 0,
|
|
280
|
+
artist_id TEXT,
|
|
281
|
+
artist_name TEXT NOT NULL,
|
|
282
|
+
PRIMARY KEY (cache_key, track_rating_key, order_index)
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
CREATE TABLE IF NOT EXISTS plex_library_cache_track_album_artists (
|
|
286
|
+
cache_key TEXT NOT NULL,
|
|
287
|
+
track_rating_key TEXT NOT NULL,
|
|
288
|
+
order_index INTEGER NOT NULL DEFAULT 0,
|
|
289
|
+
artist_id TEXT,
|
|
290
|
+
artist_name TEXT NOT NULL,
|
|
291
|
+
PRIMARY KEY (cache_key, track_rating_key, order_index)
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
CREATE INDEX IF NOT EXISTS idx_cache_artists_order
|
|
295
|
+
ON plex_library_cache_artists(cache_key, order_index);
|
|
296
|
+
CREATE INDEX IF NOT EXISTS idx_cache_artists_title
|
|
297
|
+
ON plex_library_cache_artists(cache_key, sort_key);
|
|
298
|
+
CREATE INDEX IF NOT EXISTS idx_cache_artists_rating
|
|
299
|
+
ON plex_library_cache_artists(cache_key, rating_key);
|
|
300
|
+
CREATE INDEX IF NOT EXISTS idx_cache_artists_starred
|
|
301
|
+
ON plex_library_cache_artists(cache_key, user_rating, order_index);
|
|
302
|
+
|
|
303
|
+
CREATE INDEX IF NOT EXISTS idx_cache_albums_order
|
|
304
|
+
ON plex_library_cache_albums(cache_key, order_index);
|
|
305
|
+
CREATE INDEX IF NOT EXISTS idx_cache_albums_title
|
|
306
|
+
ON plex_library_cache_albums(cache_key, sort_key);
|
|
307
|
+
CREATE INDEX IF NOT EXISTS idx_cache_albums_parent
|
|
308
|
+
ON plex_library_cache_albums(cache_key, parent_rating_key);
|
|
309
|
+
CREATE INDEX IF NOT EXISTS idx_cache_albums_rating
|
|
310
|
+
ON plex_library_cache_albums(cache_key, rating_key);
|
|
311
|
+
CREATE INDEX IF NOT EXISTS idx_cache_albums_starred
|
|
312
|
+
ON plex_library_cache_albums(cache_key, user_rating, order_index);
|
|
313
|
+
|
|
314
|
+
CREATE INDEX IF NOT EXISTS idx_cache_album_genres_name
|
|
315
|
+
ON plex_library_cache_album_genres(cache_key, genre_name);
|
|
316
|
+
|
|
317
|
+
CREATE INDEX IF NOT EXISTS idx_cache_tracks_order
|
|
318
|
+
ON plex_library_cache_tracks(cache_key, order_index);
|
|
319
|
+
CREATE INDEX IF NOT EXISTS idx_cache_tracks_title
|
|
320
|
+
ON plex_library_cache_tracks(cache_key, sort_key);
|
|
321
|
+
CREATE INDEX IF NOT EXISTS idx_cache_tracks_parent
|
|
322
|
+
ON plex_library_cache_tracks(cache_key, parent_rating_key);
|
|
323
|
+
CREATE INDEX IF NOT EXISTS idx_cache_tracks_grandparent
|
|
324
|
+
ON plex_library_cache_tracks(cache_key, grandparent_rating_key);
|
|
325
|
+
CREATE INDEX IF NOT EXISTS idx_cache_tracks_rating
|
|
326
|
+
ON plex_library_cache_tracks(cache_key, rating_key);
|
|
327
|
+
CREATE INDEX IF NOT EXISTS idx_cache_tracks_starred
|
|
328
|
+
ON plex_library_cache_tracks(cache_key, user_rating, order_index);
|
|
329
|
+
|
|
330
|
+
CREATE INDEX IF NOT EXISTS idx_cache_track_genres_name
|
|
331
|
+
ON plex_library_cache_track_genres(cache_key, genre_name);
|
|
332
|
+
CREATE INDEX IF NOT EXISTS idx_cache_track_artists_artist
|
|
333
|
+
ON plex_library_cache_track_artists(cache_key, artist_id, artist_name);
|
|
334
|
+
CREATE INDEX IF NOT EXISTS idx_cache_track_album_artists_artist
|
|
335
|
+
ON plex_library_cache_track_album_artists(cache_key, artist_id, artist_name);
|
|
336
|
+
`);
|
|
337
|
+
}
|
|
338
|
+
|
|
124
339
|
export function createRepositories(db) {
|
|
125
340
|
const createAccountStmt = db.prepare(`
|
|
126
341
|
INSERT INTO accounts (id, username, password_hash, subsonic_password_enc, enabled, created_at)
|
|
@@ -265,6 +480,23 @@ export function createRepositories(db) {
|
|
|
265
480
|
LEFT JOIN plex_selected_library psl ON psl.account_id = a.id
|
|
266
481
|
WHERE a.id = ?
|
|
267
482
|
`);
|
|
483
|
+
const listAccountPlexContextsStmt = db.prepare(`
|
|
484
|
+
SELECT
|
|
485
|
+
a.id AS account_id,
|
|
486
|
+
a.username AS username,
|
|
487
|
+
a.enabled AS enabled,
|
|
488
|
+
pl.plex_token_enc AS plex_token_enc,
|
|
489
|
+
pss.machine_id AS machine_id,
|
|
490
|
+
pss.name AS server_name,
|
|
491
|
+
pss.base_url AS server_base_url,
|
|
492
|
+
pss.server_token_enc AS server_token_enc,
|
|
493
|
+
psl.music_section_id AS music_section_id,
|
|
494
|
+
psl.music_section_name AS music_section_name
|
|
495
|
+
FROM accounts a
|
|
496
|
+
LEFT JOIN plex_links pl ON pl.account_id = a.id
|
|
497
|
+
LEFT JOIN plex_selected_server pss ON pss.account_id = a.id
|
|
498
|
+
LEFT JOIN plex_selected_library psl ON psl.account_id = a.id
|
|
499
|
+
`);
|
|
268
500
|
|
|
269
501
|
const deletePlexLinkStmt = db.prepare(`
|
|
270
502
|
DELETE FROM plex_links
|
|
@@ -409,6 +641,10 @@ export function createRepositories(db) {
|
|
|
409
641
|
return getAccountPlexContextStmt.get(accountId) || null;
|
|
410
642
|
},
|
|
411
643
|
|
|
644
|
+
listAccountPlexContexts() {
|
|
645
|
+
return listAccountPlexContextsStmt.all() || [];
|
|
646
|
+
},
|
|
647
|
+
|
|
412
648
|
unlinkPlex(accountId) {
|
|
413
649
|
const tx = db.transaction(() => {
|
|
414
650
|
deleteSelectedLibraryStmt.run(accountId);
|
package/src/plex.js
CHANGED
|
@@ -198,6 +198,83 @@ function extractMetadataList(container) {
|
|
|
198
198
|
return asArray(container.Metadata ?? container.Directory ?? container.Video ?? container.Track ?? []);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
function parseContainerTotalSize(container) {
|
|
202
|
+
const parsed = Number.parseInt(String(container?.totalSize ?? ''), 10);
|
|
203
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return parsed;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function fetchAllMetadataPages({
|
|
210
|
+
baseUrl,
|
|
211
|
+
plexToken,
|
|
212
|
+
path,
|
|
213
|
+
searchParams = null,
|
|
214
|
+
signal = undefined,
|
|
215
|
+
pageSize = 1000,
|
|
216
|
+
}) {
|
|
217
|
+
const normalizedPageSize = Math.min(Math.max(Number.parseInt(String(pageSize), 10) || 1000, 1), 2000);
|
|
218
|
+
const merged = [];
|
|
219
|
+
const seenRatingKeys = new Set();
|
|
220
|
+
let start = 0;
|
|
221
|
+
let totalSize = null;
|
|
222
|
+
let attempts = 0;
|
|
223
|
+
|
|
224
|
+
while (attempts < 2000) {
|
|
225
|
+
const payload = await fetchPmsJson(
|
|
226
|
+
baseUrl,
|
|
227
|
+
plexToken,
|
|
228
|
+
path,
|
|
229
|
+
{
|
|
230
|
+
...(searchParams || {}),
|
|
231
|
+
'X-Plex-Container-Start': start,
|
|
232
|
+
'X-Plex-Container-Size': normalizedPageSize,
|
|
233
|
+
},
|
|
234
|
+
{ signal },
|
|
235
|
+
);
|
|
236
|
+
const container = payload?.MediaContainer || {};
|
|
237
|
+
const page = extractMetadataList(container);
|
|
238
|
+
const pageTotalSize = parseContainerTotalSize(container);
|
|
239
|
+
if (pageTotalSize != null) {
|
|
240
|
+
totalSize = pageTotalSize;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (page.length === 0) {
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let added = 0;
|
|
248
|
+
for (const item of page) {
|
|
249
|
+
const ratingKey = String(item?.ratingKey ?? '').trim();
|
|
250
|
+
if (ratingKey) {
|
|
251
|
+
if (seenRatingKeys.has(ratingKey)) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
seenRatingKeys.add(ratingKey);
|
|
255
|
+
}
|
|
256
|
+
merged.push(item);
|
|
257
|
+
added += 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
start += page.length;
|
|
261
|
+
attempts += 1;
|
|
262
|
+
|
|
263
|
+
if (totalSize != null && start >= totalSize) {
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
if (page.length < normalizedPageSize) {
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
if (added === 0) {
|
|
270
|
+
// Guard against servers that ignore paging and keep returning the same page.
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return merged;
|
|
276
|
+
}
|
|
277
|
+
|
|
201
278
|
function normalizeLibrarySection(section) {
|
|
202
279
|
const parseTimestamp = (value) => {
|
|
203
280
|
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
@@ -806,27 +883,36 @@ export async function searchSectionHubs({
|
|
|
806
883
|
}
|
|
807
884
|
|
|
808
885
|
export async function listArtists({ baseUrl, plexToken, sectionId }) {
|
|
809
|
-
|
|
810
|
-
|
|
886
|
+
return fetchAllMetadataPages({
|
|
887
|
+
baseUrl,
|
|
888
|
+
plexToken,
|
|
889
|
+
path: `/library/sections/${encodeURIComponent(sectionId)}/all`,
|
|
890
|
+
searchParams: {
|
|
891
|
+
type: 8,
|
|
892
|
+
},
|
|
811
893
|
});
|
|
812
|
-
|
|
813
|
-
return extractMetadataList(payload.MediaContainer);
|
|
814
894
|
}
|
|
815
895
|
|
|
816
896
|
export async function listAlbums({ baseUrl, plexToken, sectionId }) {
|
|
817
|
-
|
|
818
|
-
|
|
897
|
+
return fetchAllMetadataPages({
|
|
898
|
+
baseUrl,
|
|
899
|
+
plexToken,
|
|
900
|
+
path: `/library/sections/${encodeURIComponent(sectionId)}/all`,
|
|
901
|
+
searchParams: {
|
|
902
|
+
type: 9,
|
|
903
|
+
},
|
|
819
904
|
});
|
|
820
|
-
|
|
821
|
-
return extractMetadataList(payload.MediaContainer);
|
|
822
905
|
}
|
|
823
906
|
|
|
824
907
|
export async function listTracks({ baseUrl, plexToken, sectionId }) {
|
|
825
|
-
|
|
826
|
-
|
|
908
|
+
return fetchAllMetadataPages({
|
|
909
|
+
baseUrl,
|
|
910
|
+
plexToken,
|
|
911
|
+
path: `/library/sections/${encodeURIComponent(sectionId)}/all`,
|
|
912
|
+
searchParams: {
|
|
913
|
+
type: 10,
|
|
914
|
+
},
|
|
827
915
|
});
|
|
828
|
-
|
|
829
|
-
return extractMetadataList(payload.MediaContainer);
|
|
830
916
|
}
|
|
831
917
|
|
|
832
918
|
export async function probeSectionFingerprint({ baseUrl, plexToken, sectionId, signal = undefined }) {
|
|
@@ -868,19 +954,25 @@ export async function getArtist({ baseUrl, plexToken, artistId }) {
|
|
|
868
954
|
}
|
|
869
955
|
|
|
870
956
|
export async function listArtistAlbums({ baseUrl, plexToken, artistId }) {
|
|
871
|
-
|
|
872
|
-
|
|
957
|
+
return fetchAllMetadataPages({
|
|
958
|
+
baseUrl,
|
|
959
|
+
plexToken,
|
|
960
|
+
path: `/library/metadata/${encodeURIComponent(artistId)}/children`,
|
|
961
|
+
searchParams: {
|
|
962
|
+
type: 9,
|
|
963
|
+
},
|
|
873
964
|
});
|
|
874
|
-
|
|
875
|
-
return extractMetadataList(payload.MediaContainer);
|
|
876
965
|
}
|
|
877
966
|
|
|
878
967
|
export async function listArtistTracks({ baseUrl, plexToken, artistId }) {
|
|
879
|
-
|
|
880
|
-
|
|
968
|
+
return fetchAllMetadataPages({
|
|
969
|
+
baseUrl,
|
|
970
|
+
plexToken,
|
|
971
|
+
path: `/library/metadata/${encodeURIComponent(artistId)}/allLeaves`,
|
|
972
|
+
searchParams: {
|
|
973
|
+
type: 10,
|
|
974
|
+
},
|
|
881
975
|
});
|
|
882
|
-
|
|
883
|
-
return extractMetadataList(payload.MediaContainer);
|
|
884
976
|
}
|
|
885
977
|
|
|
886
978
|
export async function getAlbum({ baseUrl, plexToken, albumId }) {
|
|
@@ -893,11 +985,14 @@ export async function getAlbum({ baseUrl, plexToken, albumId }) {
|
|
|
893
985
|
}
|
|
894
986
|
|
|
895
987
|
export async function listAlbumTracks({ baseUrl, plexToken, albumId }) {
|
|
896
|
-
|
|
897
|
-
|
|
988
|
+
return fetchAllMetadataPages({
|
|
989
|
+
baseUrl,
|
|
990
|
+
plexToken,
|
|
991
|
+
path: `/library/metadata/${encodeURIComponent(albumId)}/children`,
|
|
992
|
+
searchParams: {
|
|
993
|
+
type: 10,
|
|
994
|
+
},
|
|
898
995
|
});
|
|
899
|
-
|
|
900
|
-
return extractMetadataList(payload.MediaContainer);
|
|
901
996
|
}
|
|
902
997
|
|
|
903
998
|
export async function getTrack({ baseUrl, plexToken, trackId, signal = undefined }) {
|