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/html.js ADDED
@@ -0,0 +1,665 @@
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
+ function escapeHtml(value) {
22
+ return String(value)
23
+ .replaceAll('&', '&')
24
+ .replaceAll('<', '&lt;')
25
+ .replaceAll('>', '&gt;')
26
+ .replaceAll('"', '&quot;')
27
+ .replaceAll("'", '&#39;');
28
+ }
29
+
30
+ function optionCard({ label, description, value, name, checked = false }) {
31
+ return `
32
+ <label class="card-option">
33
+ <input type="radio" name="${escapeHtml(name)}" value="${escapeHtml(value)}" ${checked ? 'checked' : ''} required />
34
+ <div class="card-content">
35
+ <span class="card-title">${escapeHtml(label)}</span>
36
+ ${description ? `<span class="card-desc">${escapeHtml(description)}</span>` : ''}
37
+ </div>
38
+ </label>
39
+ `;
40
+ }
41
+
42
+ function actionLinks(links = []) {
43
+ if (links.length === 0) {
44
+ return '';
45
+ }
46
+
47
+ const content = links
48
+ .map(
49
+ (link) =>
50
+ `<a class="link-button" href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a>`,
51
+ )
52
+ .join('');
53
+
54
+ return `<div class="links">${content}</div>`;
55
+ }
56
+
57
+ export function pageTemplate({ title, body, notice = '' }) {
58
+ return `<!DOCTYPE html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8" />
62
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
63
+ <title>${escapeHtml(title)}</title>
64
+ <style>
65
+ :root {
66
+ --bg-body: #f3f4f6;
67
+ --bg-panel: #ffffff;
68
+ --text-main: #1f2937;
69
+ --text-muted: #6b7280;
70
+ --primary: #2563eb;
71
+ --primary-hover: #1d4ed8;
72
+ --danger: #dc2626;
73
+ --danger-bg: #fef2f2;
74
+ --border: #e5e7eb;
75
+ --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
76
+ }
77
+
78
+ * { box-sizing: border-box; }
79
+
80
+ body {
81
+ margin: 0;
82
+ min-height: 100vh;
83
+ color: var(--text-main);
84
+ font-family: var(--font-sans);
85
+ background-color: var(--bg-body);
86
+ display: flex;
87
+ flex-direction: column;
88
+ align-items: center;
89
+ justify-content: center;
90
+ padding: 24px;
91
+ line-height: 1.5;
92
+ }
93
+
94
+ main {
95
+ width: 100%;
96
+ max-width: 520px;
97
+ background: var(--bg-panel);
98
+ border: 1px solid var(--border);
99
+ border-radius: 6px;
100
+ padding: 40px;
101
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
102
+ }
103
+
104
+ h1 {
105
+ margin: 0 0 24px;
106
+ font-weight: 700;
107
+ font-size: 1.875rem;
108
+ letter-spacing: -0.025em;
109
+ color: var(--text-main);
110
+ text-align: center;
111
+ }
112
+
113
+ p {
114
+ margin: 0 0 24px;
115
+ color: var(--text-muted);
116
+ font-size: 1rem;
117
+ text-align: left;
118
+ }
119
+
120
+ .notice {
121
+ background: var(--danger-bg);
122
+ color: var(--danger);
123
+ border: 1px solid var(--danger);
124
+ border-radius: 6px;
125
+ padding: 12px;
126
+ margin-bottom: 24px;
127
+ font-size: 0.9rem;
128
+ text-align: center;
129
+ }
130
+
131
+ form {
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 20px;
135
+ }
136
+
137
+ label {
138
+ display: flex;
139
+ flex-direction: column;
140
+ gap: 8px;
141
+ font-weight: 500;
142
+ font-size: 0.95rem;
143
+ color: var(--text-main);
144
+ }
145
+
146
+ input:not([type="radio"]) {
147
+ appearance: none;
148
+ background-color: #fff;
149
+ border: 1px solid var(--border);
150
+ border-radius: 6px;
151
+ padding: 10px 12px;
152
+ font-size: 1rem;
153
+ color: var(--text-main);
154
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
155
+ }
156
+
157
+ input:not([type="radio"]):focus {
158
+ outline: none;
159
+ border-color: var(--primary);
160
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
161
+ }
162
+
163
+ button, .link-button {
164
+ appearance: none;
165
+ border: none;
166
+ border-radius: 6px;
167
+ background-color: var(--primary);
168
+ color: #fff;
169
+ font-size: 1rem;
170
+ font-weight: 500;
171
+ padding: 12px 20px;
172
+ cursor: pointer;
173
+ width: 100%;
174
+ text-decoration: none;
175
+ display: inline-flex;
176
+ justify-content: center;
177
+ align-items: center;
178
+ transition: background-color 0.15s ease;
179
+ }
180
+
181
+ button:hover, .link-button:hover {
182
+ background-color: var(--primary-hover);
183
+ }
184
+
185
+ .muted {
186
+ color: var(--text-muted);
187
+ font-size: 0.875rem;
188
+ text-align: center;
189
+ margin-top: 16px;
190
+ }
191
+
192
+ .links {
193
+ display: flex;
194
+ gap: 12px;
195
+ flex-wrap: wrap;
196
+ justify-content: center;
197
+ margin-top: 24px;
198
+ }
199
+
200
+ .links form {
201
+ width: 100%;
202
+ margin-top: 0;
203
+ }
204
+
205
+ /* Make link-buttons inside .links slightly less dominant if needed,
206
+ but keeping them consistent for now. */
207
+
208
+ .card-grid {
209
+ display: grid;
210
+ gap: 12px;
211
+ }
212
+
213
+ .card-option {
214
+ display: flex;
215
+ flex-direction: row;
216
+ align-items: center;
217
+ gap: 12px;
218
+ border: 1px solid var(--border);
219
+ border-radius: 6px;
220
+ padding: 16px;
221
+ background: #fff;
222
+ cursor: pointer;
223
+ transition: border-color 0.15s ease, background-color 0.15s ease;
224
+ }
225
+
226
+ .card-option input[type="radio"] {
227
+ width: 18px;
228
+ height: 18px;
229
+ accent-color: var(--primary);
230
+ margin: 0;
231
+ }
232
+
233
+ .card-content {
234
+ display: flex;
235
+ flex-direction: column;
236
+ }
237
+
238
+ .card-title {
239
+ font-weight: 600;
240
+ font-size: 1rem;
241
+ color: var(--text-main);
242
+ }
243
+
244
+ .card-desc {
245
+ font-size: 0.85rem;
246
+ color: var(--text-muted);
247
+ }
248
+
249
+ .code, .endpoint, .status {
250
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
251
+ font-size: 0.9rem;
252
+ background: #f3f4f6;
253
+ border: 1px solid var(--border);
254
+ border-radius: 6px;
255
+ padding: 8px 12px;
256
+ margin: 8px 0;
257
+ word-break: break-all;
258
+ }
259
+
260
+ .test-output {
261
+ white-space: pre-wrap;
262
+ overflow: auto;
263
+ max-height: 300px;
264
+ word-break: break-word;
265
+ }
266
+
267
+ .test-grid {
268
+ display: flex;
269
+ flex-direction: column;
270
+ gap: 16px;
271
+ margin-top: 24px;
272
+ }
273
+
274
+ @media (max-width: 600px) {
275
+ main { padding: 24px; border: none; box-shadow: none; background: transparent; }
276
+ body { background: var(--bg-panel); padding: 0; }
277
+ }
278
+ </style>
279
+ </head>
280
+ <body>
281
+ <main>
282
+ ${notice ? `<div class="notice">${escapeHtml(notice)}</div>` : ''}
283
+ ${body}
284
+ </main>
285
+ </body>
286
+ </html>`;
287
+ }
288
+
289
+ export function signupPage(notice = '') {
290
+ return pageTemplate({
291
+ title: 'Create Subsonic Account',
292
+ notice,
293
+ body: `
294
+ <h1>Create account</h1>
295
+ <p>Create the local Subsonic account first. Plex linking starts right after signup.</p>
296
+ <form method="post" action="/signup">
297
+ <label>Username
298
+ <input name="username" autocomplete="username" minlength="3" maxlength="32" required />
299
+ </label>
300
+ <label>Password
301
+ <input name="password" type="password" autocomplete="new-password" minlength="8" required />
302
+ </label>
303
+ <button type="submit">Create account</button>
304
+ </form>
305
+ ${actionLinks([{ href: '/login', label: 'Sign in' }])}
306
+ `,
307
+ });
308
+ }
309
+
310
+ export function loginPage(notice = '') {
311
+ return pageTemplate({
312
+ title: 'Sign In',
313
+ notice,
314
+ body: `
315
+ <h1>Sign in</h1>
316
+ <p>Use your local Subsonic credentials.</p>
317
+ <form method="post" action="/login">
318
+ <label>Username
319
+ <input name="username" autocomplete="username" required />
320
+ </label>
321
+ <label>Password
322
+ <input name="password" type="password" autocomplete="current-password" required />
323
+ </label>
324
+ <button type="submit">Sign in</button>
325
+ </form>
326
+ ${actionLinks([{ href: '/signup', label: 'Create account' }])}
327
+ `,
328
+ });
329
+ }
330
+
331
+ export function linkPlexPage(username, notice = '') {
332
+ return pageTemplate({
333
+ title: 'Link Plex',
334
+ notice,
335
+ body: `
336
+ <h1>Link Plex</h1>
337
+ <p>Signed in as <strong>${escapeHtml(username)}</strong>.</p>
338
+ <p>Start Plex login in a separate page. When authorization is finished, that page will close automatically.</p>
339
+ <form
340
+ method="post"
341
+ action="/link/plex/start"
342
+ target="plex-auth-popup"
343
+ onsubmit="return openPlexAuthPopup('plex-auth-popup');"
344
+ >
345
+ <button type="submit">Link Plex account</button>
346
+ </form>
347
+ <div class="links">
348
+ <form method="post" action="/logout">
349
+ <button type="submit">Sign out</button>
350
+ </form>
351
+ </div>
352
+ <script>
353
+ function openPlexAuthPopup(name) {
354
+ const width = 540;
355
+ const height = 760;
356
+ const dualScreenLeft = window.screenLeft ?? window.screenX ?? 0;
357
+ const dualScreenTop = window.screenTop ?? window.screenY ?? 0;
358
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth || screen.width;
359
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || screen.height;
360
+ const left = Math.max(0, Math.round(dualScreenLeft + (viewportWidth - width) / 2));
361
+ const top = Math.max(0, Math.round(dualScreenTop + (viewportHeight - height) / 2));
362
+ window.open(
363
+ 'about:blank',
364
+ name,
365
+ 'popup,width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',resizable=yes,scrollbars=yes',
366
+ );
367
+ return true;
368
+ }
369
+ </script>
370
+ `,
371
+ });
372
+ }
373
+
374
+ export function linkedPlexPage({
375
+ username,
376
+ serverName = null,
377
+ libraryName = null,
378
+ notice = '',
379
+ }) {
380
+ const statusLines = [
381
+ `<p><strong>User:</strong> ${escapeHtml(username)}</p>`,
382
+ `<p><strong>Plex link:</strong> Connected</p>`,
383
+ `<p><strong>Server:</strong> ${serverName ? escapeHtml(serverName) : 'Not selected yet'}</p>`,
384
+ `<p><strong>Music library:</strong> ${libraryName ? escapeHtml(libraryName) : 'Not selected yet'}</p>`,
385
+ ].join('');
386
+
387
+ return pageTemplate({
388
+ title: 'Plex Linked',
389
+ notice,
390
+ body: `
391
+ <h1>Plex linked</h1>
392
+ ${statusLines}
393
+ <div class="links">
394
+ <a class="link-button" href="/link/plex/server">Select server</a>
395
+ <a class="link-button" href="/link/plex/library">Select library</a>
396
+ <a class="link-button" href="/test">Test page</a>
397
+ </div>
398
+ <div class="links">
399
+ <form
400
+ method="post"
401
+ action="/link/plex/start"
402
+ target="plex-auth-popup"
403
+ onsubmit="return openPlexAuthPopup('plex-auth-popup');"
404
+ >
405
+ <button type="submit">Re-authorize Plex</button>
406
+ </form>
407
+ <form method="post" action="/account/plex/unlink">
408
+ <button type="submit">Unlink Plex</button>
409
+ </form>
410
+ <form method="post" action="/logout">
411
+ <button type="submit">Sign out</button>
412
+ </form>
413
+ </div>
414
+ <form method="post" action="/account/password" style="margin-top: 48px; border-top: 1px solid var(--border); padding-top: 24px;">
415
+ <h2 style="font-size: 1.25rem; margin-bottom: 20px;">Change Password</h2>
416
+ <label>Current password
417
+ <input name="currentPassword" type="password" autocomplete="current-password" required />
418
+ </label>
419
+ <label>New password
420
+ <input name="newPassword" type="password" autocomplete="new-password" minlength="8" required />
421
+ </label>
422
+ <label>Confirm new password
423
+ <input name="confirmPassword" type="password" autocomplete="new-password" minlength="8" required />
424
+ </label>
425
+ <button type="submit">Update password</button>
426
+ </form>
427
+ <script>
428
+ function openPlexAuthPopup(name) {
429
+ const width = 540;
430
+ const height = 760;
431
+ const dualScreenLeft = window.screenLeft ?? window.screenX ?? 0;
432
+ const dualScreenTop = window.screenTop ?? window.screenY ?? 0;
433
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth || screen.width;
434
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || screen.height;
435
+ const left = Math.max(0, Math.round(dualScreenLeft + (viewportWidth - width) / 2));
436
+ const top = Math.max(0, Math.round(dualScreenTop + (viewportHeight - height) / 2));
437
+ window.open(
438
+ 'about:blank',
439
+ name,
440
+ 'popup,width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',resizable=yes,scrollbars=yes',
441
+ );
442
+ return true;
443
+ }
444
+ </script>
445
+ `,
446
+ });
447
+ }
448
+
449
+ export function plexPinPage({ authUrl, sid, phase }) {
450
+ return pageTemplate({
451
+ title: 'Authorize Plex',
452
+ body: `
453
+ <h1>Authorize Plex</h1>
454
+ <p id="hint">Finishing Plex authorization.</p>
455
+ <div id="status" class="status">Preparing auth flow...</div>
456
+ <div class="links">
457
+ <a id="manualLink" class="link-button" href="${escapeHtml(authUrl)}" rel="noopener noreferrer">Open Plex Auth</a>
458
+ </div>
459
+ <script>
460
+ const sid = ${JSON.stringify(sid)};
461
+ const authUrl = ${JSON.stringify(authUrl)};
462
+ const phase = ${JSON.stringify(phase)};
463
+ const statusEl = document.getElementById('status');
464
+ const hintEl = document.getElementById('hint');
465
+ const manualLinkEl = document.getElementById('manualLink');
466
+
467
+ function closeOrShowMessage(nextUrl) {
468
+ let closed = false;
469
+ if (window.opener && !window.opener.closed) {
470
+ try {
471
+ window.opener.location.assign(nextUrl);
472
+ } catch {}
473
+ }
474
+ try {
475
+ window.close();
476
+ closed = window.closed;
477
+ } catch {}
478
+
479
+ if (!closed) {
480
+ hintEl.textContent = 'Plex linked. You can close this page now.';
481
+ statusEl.textContent = 'Plex authorization completed.';
482
+ manualLinkEl.style.display = 'none';
483
+ }
484
+ }
485
+
486
+ async function poll() {
487
+ try {
488
+ const res = await fetch('/link/plex/poll?sid=' + encodeURIComponent(sid), { cache: 'no-store' });
489
+ const data = await res.json();
490
+ if (data.status === 'linked') {
491
+ statusEl.textContent = 'Plex linked. Attempting to close...';
492
+ closeOrShowMessage(data.next || '/link/plex/server');
493
+ return;
494
+ }
495
+ if (data.status === 'expired') {
496
+ hintEl.textContent = 'Plex authorization expired.';
497
+ statusEl.textContent = 'Please close this page and start again.';
498
+ return;
499
+ }
500
+ statusEl.textContent = 'Waiting for Plex authorization...';
501
+ } catch (_error) {
502
+ statusEl.textContent = 'Polling failed, retrying...';
503
+ }
504
+ setTimeout(poll, 2500);
505
+ }
506
+
507
+ if (phase === 'launch') {
508
+ hintEl.textContent = 'Opening Plex login...';
509
+ statusEl.textContent = 'If nothing happens, click "Open Plex Auth".';
510
+ setTimeout(() => {
511
+ window.location.assign(authUrl);
512
+ }, 150);
513
+ } else {
514
+ hintEl.textContent = 'Authorization complete. Verifying link...';
515
+ statusEl.textContent = 'Checking Plex link status...';
516
+ manualLinkEl.style.display = 'none';
517
+ poll();
518
+ }
519
+ </script>
520
+ `,
521
+ });
522
+ }
523
+
524
+ export function plexServerPage({ servers, selectedMachineId = null, notice = '' }) {
525
+ const options = servers
526
+ .map((server) =>
527
+ optionCard({
528
+ name: 'serverChoice',
529
+ value: server.encodedChoice,
530
+ checked: selectedMachineId ? selectedMachineId === server.machineId : false,
531
+ label: server.name,
532
+ description: `${server.baseUrl}`,
533
+ }),
534
+ )
535
+ .join('');
536
+
537
+ return pageTemplate({
538
+ title: 'Select Plex Server',
539
+ notice,
540
+ body: `
541
+ <h1>Select Plex Server</h1>
542
+ <p>Choose which Plex Media Server this account should use.</p>
543
+ ${
544
+ servers.length
545
+ ? `<form method="post" action="/link/plex/server"><div class="card-grid">${options}</div><button type="submit">Save server</button></form>`
546
+ : `<p>No servers found. Confirm the Plex account has a reachable server on your LAN.</p>`
547
+ }
548
+ ${actionLinks([{ href: '/link/plex', label: 'Back' }])}
549
+ `,
550
+ });
551
+ }
552
+
553
+ export function plexLibraryPage({ sections, selectedSectionId = null, notice = '' }) {
554
+ const options = sections
555
+ .map((section) =>
556
+ optionCard({
557
+ name: 'libraryChoice',
558
+ value: section.encodedChoice,
559
+ checked: selectedSectionId ? selectedSectionId === section.id : false,
560
+ label: section.title
561
+ }),
562
+ )
563
+ .join('');
564
+
565
+ return pageTemplate({
566
+ title: 'Select Music Library',
567
+ notice,
568
+ body: `
569
+ <h1>Select Music Library</h1>
570
+ <p>Pick the Plex music section to expose through Subsonic endpoints.</p>
571
+ ${
572
+ sections.length
573
+ ? `<form method="post" action="/link/plex/library"><div class="card-grid">${options}</div><button type="submit">Save library</button></form>`
574
+ : `<p>No music sections found on this server.</p>`
575
+ }
576
+ ${actionLinks([{ href: '/link/plex/server', label: 'Back' }])}
577
+ `,
578
+ });
579
+ }
580
+
581
+ export function testPage({ username }) {
582
+ const restBase = '/rest';
583
+
584
+ return pageTemplate({
585
+ title: 'Test Page',
586
+ body: `
587
+ <h1>Test page</h1>
588
+ <p>The bridge is ready for this account.</p>
589
+ <p><strong>Username:</strong> ${escapeHtml(username)}</p>
590
+ <p><strong>Subsonic API base:</strong></p>
591
+ <div class="endpoint">${escapeHtml(restBase)}</div>
592
+
593
+ <div class="test-grid">
594
+ <label>Manual test password
595
+ <input id="pw" type="password" placeholder="Enter local account password" />
596
+ </label>
597
+ <div class="links">
598
+ <button type="button" onclick="runTest('ping.view')">Ping</button>
599
+ <button type="button" onclick="runTest('getArtists.view')">List artists</button>
600
+ </div>
601
+ <pre id="testResult" class="status test-output">Run a test endpoint to inspect JSON output.</pre>
602
+ </div>
603
+
604
+ ${actionLinks([{ href: '/link/plex/server', label: 'Change server' }, { href: '/link/plex/library', label: 'Change library' }])}
605
+
606
+ <script>
607
+ const username = ${JSON.stringify(username)};
608
+ async function runTest(endpoint) {
609
+ const password = document.getElementById('pw').value;
610
+ const out = document.getElementById('testResult');
611
+ if (!password) {
612
+ out.textContent = 'Enter password first.';
613
+ return;
614
+ }
615
+ const params = new URLSearchParams({
616
+ u: username,
617
+ p: password,
618
+ c: 'web',
619
+ v: '1.16.1',
620
+ f: 'json',
621
+ });
622
+ const url = '/rest/' + endpoint + '?' + params.toString();
623
+ out.textContent = 'Requesting ' + url + ' ...';
624
+ try {
625
+ const res = await fetch(url, {
626
+ cache: 'no-store',
627
+ headers: {
628
+ Accept: 'application/json',
629
+ },
630
+ });
631
+ const text = await res.text();
632
+ out.textContent = formatResponse(text);
633
+ } catch (err) {
634
+ out.textContent = 'Request failed: ' + err.message;
635
+ }
636
+ }
637
+
638
+ function formatResponse(text) {
639
+ const trimmed = String(text || '').trim();
640
+ if (!trimmed) {
641
+ return '';
642
+ }
643
+
644
+ try {
645
+ return JSON.stringify(JSON.parse(trimmed), null, 2);
646
+ } catch {
647
+ return trimmed;
648
+ }
649
+ }
650
+ </script>
651
+ `,
652
+ });
653
+ }
654
+
655
+ export function encodeChoicePayload(payload) {
656
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
657
+ }
658
+
659
+ export function decodeChoicePayload(value) {
660
+ try {
661
+ return JSON.parse(Buffer.from(String(value), 'base64url').toString('utf8'));
662
+ } catch {
663
+ return null;
664
+ }
665
+ }
package/src/index.js ADDED
@@ -0,0 +1,35 @@
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 { buildServer } from './server.js';
22
+ import { loadConfig } from './config.js';
23
+
24
+ const config = loadConfig();
25
+ const app = await buildServer(config);
26
+
27
+ try {
28
+ await app.listen({
29
+ host: config.bindHost,
30
+ port: config.port,
31
+ });
32
+ } catch (error) {
33
+ app.log.error(error, 'Failed to start server');
34
+ process.exit(1);
35
+ }