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/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
+ }