plexsonic 0.1.0
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 +16 -0
- package/LICENSE +203 -0
- package/README.md +192 -0
- package/bin/plexsonic.js +22 -0
- package/package.json +45 -0
- package/src/config.js +96 -0
- package/src/db.js +423 -0
- package/src/html.js +665 -0
- package/src/index.js +35 -0
- package/src/plex.js +1335 -0
- package/src/server.js +5572 -0
- package/src/subsonic-xml.js +424 -0
- package/src/token-crypto.js +84 -0
package/src/db.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/* Copyright Yukino Song, SudoMaker Ltd.
|
|
2
|
+
*
|
|
3
|
+
* Licensed to the Apache Software Foundation (ASF) under one
|
|
4
|
+
* or more contributor license agreements. See the NOTICE file
|
|
5
|
+
* distributed with this work for additional information
|
|
6
|
+
* regarding copyright ownership. The ASF licenses this file
|
|
7
|
+
* to you under the Apache License, Version 2.0 (the
|
|
8
|
+
* "License"); you may not use this file except in compliance
|
|
9
|
+
* with the License. You may obtain a copy of the License at
|
|
10
|
+
*
|
|
11
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
*
|
|
13
|
+
* Unless required by applicable law or agreed to in writing,
|
|
14
|
+
* software distributed under the License is distributed on an
|
|
15
|
+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
16
|
+
* KIND, either express or implied. See the License for the
|
|
17
|
+
* specific language governing permissions and limitations
|
|
18
|
+
* under the License.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import Database from 'better-sqlite3';
|
|
24
|
+
|
|
25
|
+
function ensureDbDir(dbPath) {
|
|
26
|
+
const dir = path.dirname(path.resolve(dbPath));
|
|
27
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nowEpochSeconds() {
|
|
31
|
+
return Math.floor(Date.now() / 1000);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function openDatabase(dbPath) {
|
|
35
|
+
ensureDbDir(dbPath);
|
|
36
|
+
|
|
37
|
+
const db = new Database(dbPath);
|
|
38
|
+
db.pragma('journal_mode = WAL');
|
|
39
|
+
db.pragma('foreign_keys = ON');
|
|
40
|
+
|
|
41
|
+
return db;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function migrate(db) {
|
|
45
|
+
db.exec(`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
47
|
+
id TEXT PRIMARY KEY,
|
|
48
|
+
username TEXT NOT NULL UNIQUE,
|
|
49
|
+
password_hash TEXT NOT NULL,
|
|
50
|
+
subsonic_password_enc BLOB,
|
|
51
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
52
|
+
created_at INTEGER NOT NULL
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS plex_links (
|
|
56
|
+
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
|
57
|
+
plex_token_enc BLOB NOT NULL,
|
|
58
|
+
plex_token_created_at INTEGER NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS plex_selected_server (
|
|
62
|
+
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
|
63
|
+
machine_id TEXT NOT NULL,
|
|
64
|
+
name TEXT NOT NULL,
|
|
65
|
+
base_url TEXT NOT NULL,
|
|
66
|
+
server_token_enc BLOB,
|
|
67
|
+
updated_at INTEGER NOT NULL
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS plex_selected_library (
|
|
71
|
+
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
|
|
72
|
+
music_section_id TEXT NOT NULL,
|
|
73
|
+
music_section_name TEXT,
|
|
74
|
+
updated_at INTEGER NOT NULL
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE TABLE IF NOT EXISTS plex_pin_sessions (
|
|
78
|
+
id TEXT PRIMARY KEY,
|
|
79
|
+
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
80
|
+
pin_id TEXT NOT NULL,
|
|
81
|
+
code TEXT NOT NULL,
|
|
82
|
+
auth_url TEXT NOT NULL,
|
|
83
|
+
created_at INTEGER NOT NULL,
|
|
84
|
+
status TEXT NOT NULL,
|
|
85
|
+
last_polled_at INTEGER
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_plex_pin_sessions_account_id
|
|
89
|
+
ON plex_pin_sessions(account_id);
|
|
90
|
+
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_plex_pin_sessions_status
|
|
92
|
+
ON plex_pin_sessions(status);
|
|
93
|
+
|
|
94
|
+
CREATE TABLE IF NOT EXISTS web_sessions (
|
|
95
|
+
session_id TEXT PRIMARY KEY,
|
|
96
|
+
session_json TEXT NOT NULL,
|
|
97
|
+
expires_at INTEGER,
|
|
98
|
+
updated_at INTEGER NOT NULL
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at
|
|
102
|
+
ON web_sessions(expires_at);
|
|
103
|
+
`);
|
|
104
|
+
|
|
105
|
+
const accountColumns = db.prepare(`PRAGMA table_info(accounts)`).all();
|
|
106
|
+
const hasSubsonicPasswordEnc = accountColumns.some((column) => column.name === 'subsonic_password_enc');
|
|
107
|
+
if (!hasSubsonicPasswordEnc) {
|
|
108
|
+
db.exec('ALTER TABLE accounts ADD COLUMN subsonic_password_enc BLOB');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const selectedLibraryColumns = db.prepare(`PRAGMA table_info(plex_selected_library)`).all();
|
|
112
|
+
const hasMusicSectionName = selectedLibraryColumns.some((column) => column.name === 'music_section_name');
|
|
113
|
+
if (!hasMusicSectionName) {
|
|
114
|
+
db.exec('ALTER TABLE plex_selected_library ADD COLUMN music_section_name TEXT');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const selectedServerColumns = db.prepare(`PRAGMA table_info(plex_selected_server)`).all();
|
|
118
|
+
const hasServerTokenEnc = selectedServerColumns.some((column) => column.name === 'server_token_enc');
|
|
119
|
+
if (!hasServerTokenEnc) {
|
|
120
|
+
db.exec('ALTER TABLE plex_selected_server ADD COLUMN server_token_enc BLOB');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createRepositories(db) {
|
|
125
|
+
const createAccountStmt = db.prepare(`
|
|
126
|
+
INSERT INTO accounts (id, username, password_hash, subsonic_password_enc, enabled, created_at)
|
|
127
|
+
VALUES (@id, @username, @password_hash, @subsonic_password_enc, 1, @created_at)
|
|
128
|
+
`);
|
|
129
|
+
|
|
130
|
+
const getAccountByUsernameStmt = db.prepare(`
|
|
131
|
+
SELECT id, username, password_hash, subsonic_password_enc, enabled, created_at
|
|
132
|
+
FROM accounts
|
|
133
|
+
WHERE username = ?
|
|
134
|
+
`);
|
|
135
|
+
|
|
136
|
+
const getAccountByIdStmt = db.prepare(`
|
|
137
|
+
SELECT id, username, enabled, created_at
|
|
138
|
+
FROM accounts
|
|
139
|
+
WHERE id = ?
|
|
140
|
+
`);
|
|
141
|
+
|
|
142
|
+
const updateSubsonicPasswordEncStmt = db.prepare(`
|
|
143
|
+
UPDATE accounts
|
|
144
|
+
SET subsonic_password_enc = @subsonic_password_enc
|
|
145
|
+
WHERE id = @id
|
|
146
|
+
`);
|
|
147
|
+
|
|
148
|
+
const updateAccountPasswordStmt = db.prepare(`
|
|
149
|
+
UPDATE accounts
|
|
150
|
+
SET password_hash = @password_hash,
|
|
151
|
+
subsonic_password_enc = @subsonic_password_enc
|
|
152
|
+
WHERE id = @id
|
|
153
|
+
`);
|
|
154
|
+
|
|
155
|
+
const hasAnyAccountStmt = db.prepare(`
|
|
156
|
+
SELECT 1 AS exists_flag
|
|
157
|
+
FROM accounts
|
|
158
|
+
LIMIT 1
|
|
159
|
+
`);
|
|
160
|
+
|
|
161
|
+
const upsertPlexLinkStmt = db.prepare(`
|
|
162
|
+
INSERT INTO plex_links (account_id, plex_token_enc, plex_token_created_at)
|
|
163
|
+
VALUES (@account_id, @plex_token_enc, @plex_token_created_at)
|
|
164
|
+
ON CONFLICT(account_id)
|
|
165
|
+
DO UPDATE SET
|
|
166
|
+
plex_token_enc = excluded.plex_token_enc,
|
|
167
|
+
plex_token_created_at = excluded.plex_token_created_at
|
|
168
|
+
`);
|
|
169
|
+
|
|
170
|
+
const getPlexLinkByAccountIdStmt = db.prepare(`
|
|
171
|
+
SELECT account_id, plex_token_enc, plex_token_created_at
|
|
172
|
+
FROM plex_links
|
|
173
|
+
WHERE account_id = ?
|
|
174
|
+
`);
|
|
175
|
+
|
|
176
|
+
const upsertSelectedServerStmt = db.prepare(`
|
|
177
|
+
INSERT INTO plex_selected_server (account_id, machine_id, name, base_url, server_token_enc, updated_at)
|
|
178
|
+
VALUES (@account_id, @machine_id, @name, @base_url, @server_token_enc, @updated_at)
|
|
179
|
+
ON CONFLICT(account_id)
|
|
180
|
+
DO UPDATE SET
|
|
181
|
+
machine_id = excluded.machine_id,
|
|
182
|
+
name = excluded.name,
|
|
183
|
+
base_url = excluded.base_url,
|
|
184
|
+
server_token_enc = excluded.server_token_enc,
|
|
185
|
+
updated_at = excluded.updated_at
|
|
186
|
+
`);
|
|
187
|
+
|
|
188
|
+
const getSelectedServerByAccountIdStmt = db.prepare(`
|
|
189
|
+
SELECT account_id, machine_id, name, base_url, server_token_enc, updated_at
|
|
190
|
+
FROM plex_selected_server
|
|
191
|
+
WHERE account_id = ?
|
|
192
|
+
`);
|
|
193
|
+
|
|
194
|
+
const upsertSelectedLibraryStmt = db.prepare(`
|
|
195
|
+
INSERT INTO plex_selected_library (account_id, music_section_id, music_section_name, updated_at)
|
|
196
|
+
VALUES (@account_id, @music_section_id, @music_section_name, @updated_at)
|
|
197
|
+
ON CONFLICT(account_id)
|
|
198
|
+
DO UPDATE SET
|
|
199
|
+
music_section_id = excluded.music_section_id,
|
|
200
|
+
music_section_name = excluded.music_section_name,
|
|
201
|
+
updated_at = excluded.updated_at
|
|
202
|
+
`);
|
|
203
|
+
|
|
204
|
+
const getSelectedLibraryByAccountIdStmt = db.prepare(`
|
|
205
|
+
SELECT account_id, music_section_id, music_section_name, updated_at
|
|
206
|
+
FROM plex_selected_library
|
|
207
|
+
WHERE account_id = ?
|
|
208
|
+
`);
|
|
209
|
+
|
|
210
|
+
const createPinSessionStmt = db.prepare(`
|
|
211
|
+
INSERT INTO plex_pin_sessions (
|
|
212
|
+
id,
|
|
213
|
+
account_id,
|
|
214
|
+
pin_id,
|
|
215
|
+
code,
|
|
216
|
+
auth_url,
|
|
217
|
+
created_at,
|
|
218
|
+
status,
|
|
219
|
+
last_polled_at
|
|
220
|
+
) VALUES (
|
|
221
|
+
@id,
|
|
222
|
+
@account_id,
|
|
223
|
+
@pin_id,
|
|
224
|
+
@code,
|
|
225
|
+
@auth_url,
|
|
226
|
+
@created_at,
|
|
227
|
+
@status,
|
|
228
|
+
NULL
|
|
229
|
+
)
|
|
230
|
+
`);
|
|
231
|
+
|
|
232
|
+
const getPinSessionByIdStmt = db.prepare(`
|
|
233
|
+
SELECT id, account_id, pin_id, code, auth_url, created_at, status, last_polled_at
|
|
234
|
+
FROM plex_pin_sessions
|
|
235
|
+
WHERE id = ?
|
|
236
|
+
`);
|
|
237
|
+
|
|
238
|
+
const updatePinSessionPollTimeStmt = db.prepare(`
|
|
239
|
+
UPDATE plex_pin_sessions
|
|
240
|
+
SET last_polled_at = @last_polled_at
|
|
241
|
+
WHERE id = @id
|
|
242
|
+
`);
|
|
243
|
+
|
|
244
|
+
const updatePinSessionStatusStmt = db.prepare(`
|
|
245
|
+
UPDATE plex_pin_sessions
|
|
246
|
+
SET status = @status
|
|
247
|
+
WHERE id = @id
|
|
248
|
+
`);
|
|
249
|
+
|
|
250
|
+
const getAccountPlexContextStmt = db.prepare(`
|
|
251
|
+
SELECT
|
|
252
|
+
a.id AS account_id,
|
|
253
|
+
a.username AS username,
|
|
254
|
+
a.enabled AS enabled,
|
|
255
|
+
pl.plex_token_enc AS plex_token_enc,
|
|
256
|
+
pss.machine_id AS machine_id,
|
|
257
|
+
pss.name AS server_name,
|
|
258
|
+
pss.base_url AS server_base_url,
|
|
259
|
+
pss.server_token_enc AS server_token_enc,
|
|
260
|
+
psl.music_section_id AS music_section_id,
|
|
261
|
+
psl.music_section_name AS music_section_name
|
|
262
|
+
FROM accounts a
|
|
263
|
+
LEFT JOIN plex_links pl ON pl.account_id = a.id
|
|
264
|
+
LEFT JOIN plex_selected_server pss ON pss.account_id = a.id
|
|
265
|
+
LEFT JOIN plex_selected_library psl ON psl.account_id = a.id
|
|
266
|
+
WHERE a.id = ?
|
|
267
|
+
`);
|
|
268
|
+
|
|
269
|
+
const deletePlexLinkStmt = db.prepare(`
|
|
270
|
+
DELETE FROM plex_links
|
|
271
|
+
WHERE account_id = ?
|
|
272
|
+
`);
|
|
273
|
+
|
|
274
|
+
const deleteSelectedServerStmt = db.prepare(`
|
|
275
|
+
DELETE FROM plex_selected_server
|
|
276
|
+
WHERE account_id = ?
|
|
277
|
+
`);
|
|
278
|
+
|
|
279
|
+
const deleteSelectedLibraryStmt = db.prepare(`
|
|
280
|
+
DELETE FROM plex_selected_library
|
|
281
|
+
WHERE account_id = ?
|
|
282
|
+
`);
|
|
283
|
+
|
|
284
|
+
const deletePinSessionsStmt = db.prepare(`
|
|
285
|
+
DELETE FROM plex_pin_sessions
|
|
286
|
+
WHERE account_id = ?
|
|
287
|
+
`);
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
createAccount({ id, username, passwordHash, subsonicPasswordEnc = null }) {
|
|
291
|
+
createAccountStmt.run({
|
|
292
|
+
id,
|
|
293
|
+
username,
|
|
294
|
+
password_hash: passwordHash,
|
|
295
|
+
subsonic_password_enc: subsonicPasswordEnc,
|
|
296
|
+
created_at: nowEpochSeconds(),
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
getAccountByUsername(username) {
|
|
301
|
+
return getAccountByUsernameStmt.get(username) || null;
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
getAccountById(accountId) {
|
|
305
|
+
return getAccountByIdStmt.get(accountId) || null;
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
updateSubsonicPasswordEnc(accountId, subsonicPasswordEnc) {
|
|
309
|
+
updateSubsonicPasswordEncStmt.run({
|
|
310
|
+
id: accountId,
|
|
311
|
+
subsonic_password_enc: subsonicPasswordEnc,
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
updateAccountPassword({ accountId, passwordHash, subsonicPasswordEnc }) {
|
|
316
|
+
updateAccountPasswordStmt.run({
|
|
317
|
+
id: accountId,
|
|
318
|
+
password_hash: passwordHash,
|
|
319
|
+
subsonic_password_enc: subsonicPasswordEnc,
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
hasAnyAccount() {
|
|
324
|
+
return Boolean(hasAnyAccountStmt.get());
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
upsertPlexLink({ accountId, encryptedToken }) {
|
|
328
|
+
upsertPlexLinkStmt.run({
|
|
329
|
+
account_id: accountId,
|
|
330
|
+
plex_token_enc: encryptedToken,
|
|
331
|
+
plex_token_created_at: nowEpochSeconds(),
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
getPlexLinkByAccountId(accountId) {
|
|
336
|
+
return getPlexLinkByAccountIdStmt.get(accountId) || null;
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
upsertSelectedServer({ accountId, machineId, name, baseUrl, encryptedServerToken = null }) {
|
|
340
|
+
upsertSelectedServerStmt.run({
|
|
341
|
+
account_id: accountId,
|
|
342
|
+
machine_id: machineId,
|
|
343
|
+
name,
|
|
344
|
+
base_url: baseUrl,
|
|
345
|
+
server_token_enc: encryptedServerToken,
|
|
346
|
+
updated_at: nowEpochSeconds(),
|
|
347
|
+
});
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
getSelectedServerByAccountId(accountId) {
|
|
351
|
+
return getSelectedServerByAccountIdStmt.get(accountId) || null;
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
upsertSelectedLibrary({ accountId, musicSectionId, musicSectionName = null }) {
|
|
355
|
+
upsertSelectedLibraryStmt.run({
|
|
356
|
+
account_id: accountId,
|
|
357
|
+
music_section_id: musicSectionId,
|
|
358
|
+
music_section_name: musicSectionName,
|
|
359
|
+
updated_at: nowEpochSeconds(),
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
getSelectedLibraryByAccountId(accountId) {
|
|
364
|
+
return getSelectedLibraryByAccountIdStmt.get(accountId) || null;
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
createPinSession({ id, accountId, pinId, code, authUrl }) {
|
|
368
|
+
createPinSessionStmt.run({
|
|
369
|
+
id,
|
|
370
|
+
account_id: accountId,
|
|
371
|
+
pin_id: pinId,
|
|
372
|
+
code,
|
|
373
|
+
auth_url: authUrl,
|
|
374
|
+
created_at: nowEpochSeconds(),
|
|
375
|
+
status: 'pending',
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
getPinSessionById(id) {
|
|
380
|
+
return getPinSessionByIdStmt.get(id) || null;
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
updatePinSessionPollTime(id) {
|
|
384
|
+
updatePinSessionPollTimeStmt.run({
|
|
385
|
+
id,
|
|
386
|
+
last_polled_at: nowEpochSeconds(),
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
updatePinSessionStatus(id, status) {
|
|
391
|
+
updatePinSessionStatusStmt.run({ id, status });
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
markPinLinkedAndStoreToken({ pinSessionId, accountId, encryptedToken }) {
|
|
395
|
+
const tx = db.transaction(() => {
|
|
396
|
+
upsertPlexLinkStmt.run({
|
|
397
|
+
account_id: accountId,
|
|
398
|
+
plex_token_enc: encryptedToken,
|
|
399
|
+
plex_token_created_at: nowEpochSeconds(),
|
|
400
|
+
});
|
|
401
|
+
updatePinSessionStatusStmt.run({ id: pinSessionId, status: 'linked' });
|
|
402
|
+
updatePinSessionPollTimeStmt.run({ id: pinSessionId, last_polled_at: nowEpochSeconds() });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
tx();
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
getAccountPlexContext(accountId) {
|
|
409
|
+
return getAccountPlexContextStmt.get(accountId) || null;
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
unlinkPlex(accountId) {
|
|
413
|
+
const tx = db.transaction(() => {
|
|
414
|
+
deleteSelectedLibraryStmt.run(accountId);
|
|
415
|
+
deleteSelectedServerStmt.run(accountId);
|
|
416
|
+
deletePlexLinkStmt.run(accountId);
|
|
417
|
+
deletePinSessionsStmt.run(accountId);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
tx();
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|