pakt 0.2.1__py3-none-any.whl
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.
- pakt/__init__.py +3 -0
- pakt/__main__.py +6 -0
- pakt/assets/icon.png +0 -0
- pakt/assets/icon.svg +10 -0
- pakt/assets/logo.png +0 -0
- pakt/cli.py +814 -0
- pakt/config.py +222 -0
- pakt/models.py +109 -0
- pakt/plex.py +758 -0
- pakt/scheduler.py +153 -0
- pakt/sync.py +1490 -0
- pakt/trakt.py +575 -0
- pakt/tray.py +137 -0
- pakt/web/__init__.py +5 -0
- pakt/web/app.py +991 -0
- pakt/web/templates/index.html +2327 -0
- pakt-0.2.1.dist-info/METADATA +207 -0
- pakt-0.2.1.dist-info/RECORD +20 -0
- pakt-0.2.1.dist-info/WHEEL +4 -0
- pakt-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,2327 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Pakt - Plex/Trakt Sync</title>
|
|
7
|
+
<link rel="icon" type="image/png" href="/favicon.ico">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #1a1a2e;
|
|
11
|
+
--bg-card: #16213e;
|
|
12
|
+
--accent: #2dd4bf;
|
|
13
|
+
--accent-hover: #14b8a6;
|
|
14
|
+
--text: #eee;
|
|
15
|
+
--text-dim: #888;
|
|
16
|
+
--success: #4ade80;
|
|
17
|
+
--warning: #fbbf24;
|
|
18
|
+
--error: #ef4444;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* {
|
|
22
|
+
box-sizing: border-box;
|
|
23
|
+
margin: 0;
|
|
24
|
+
padding: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
body {
|
|
28
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
29
|
+
background: var(--bg);
|
|
30
|
+
color: var(--text);
|
|
31
|
+
min-height: 100vh;
|
|
32
|
+
padding: 2rem;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.container {
|
|
36
|
+
max-width: 1000px;
|
|
37
|
+
margin: 0 auto;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
header {
|
|
41
|
+
display: flex;
|
|
42
|
+
justify-content: space-between;
|
|
43
|
+
align-items: center;
|
|
44
|
+
margin-bottom: 1.5rem;
|
|
45
|
+
padding-bottom: 1rem;
|
|
46
|
+
border-bottom: 1px solid #333;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
h1 {
|
|
50
|
+
font-size: 1.5rem;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
h1 span {
|
|
55
|
+
color: var(--accent);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.header-logo {
|
|
59
|
+
height: 3rem;
|
|
60
|
+
width: auto;
|
|
61
|
+
vertical-align: middle;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.nav-tabs {
|
|
65
|
+
display: flex;
|
|
66
|
+
gap: 0.25rem;
|
|
67
|
+
background: var(--bg-card);
|
|
68
|
+
padding: 0.25rem;
|
|
69
|
+
border-radius: 0.5rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.nav-tab {
|
|
73
|
+
padding: 0.5rem 1rem;
|
|
74
|
+
border: none;
|
|
75
|
+
background: transparent;
|
|
76
|
+
color: var(--text-dim);
|
|
77
|
+
font-size: 0.875rem;
|
|
78
|
+
font-weight: 500;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
border-radius: 0.375rem;
|
|
81
|
+
transition: all 0.2s;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.nav-tab:hover {
|
|
85
|
+
color: var(--text);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.nav-tab.active {
|
|
89
|
+
background: var(--accent);
|
|
90
|
+
color: white;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.status-badge {
|
|
94
|
+
padding: 0.375rem 0.75rem;
|
|
95
|
+
border-radius: 2rem;
|
|
96
|
+
font-size: 0.75rem;
|
|
97
|
+
font-weight: 500;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.status-badge.ok { background: rgba(74, 222, 128, 0.2); color: var(--success); }
|
|
101
|
+
.status-badge.error { background: rgba(239, 68, 68, 0.2); color: var(--error); }
|
|
102
|
+
.status-badge.running { background: rgba(251, 191, 36, 0.2); color: var(--warning); }
|
|
103
|
+
|
|
104
|
+
/* Setup Wizard Styles */
|
|
105
|
+
.wizard {
|
|
106
|
+
max-width: 600px;
|
|
107
|
+
margin: 0 auto;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.wizard-header {
|
|
111
|
+
text-align: center;
|
|
112
|
+
margin-bottom: 2rem;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.wizard-header h2 {
|
|
116
|
+
font-size: 1.5rem;
|
|
117
|
+
margin-bottom: 0.5rem;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.wizard-header p {
|
|
121
|
+
color: var(--text-dim);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.steps {
|
|
125
|
+
display: flex;
|
|
126
|
+
justify-content: center;
|
|
127
|
+
gap: 1rem;
|
|
128
|
+
margin-bottom: 2rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.step {
|
|
132
|
+
display: flex;
|
|
133
|
+
align-items: center;
|
|
134
|
+
gap: 0.5rem;
|
|
135
|
+
color: var(--text-dim);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.step.active {
|
|
139
|
+
color: var(--text);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.step.done {
|
|
143
|
+
color: var(--success);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.step-number {
|
|
147
|
+
width: 2rem;
|
|
148
|
+
height: 2rem;
|
|
149
|
+
border-radius: 50%;
|
|
150
|
+
background: #333;
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
justify-content: center;
|
|
154
|
+
font-weight: 600;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.step.active .step-number {
|
|
158
|
+
background: var(--accent);
|
|
159
|
+
color: white;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.step.done .step-number {
|
|
163
|
+
background: var(--success);
|
|
164
|
+
color: #1a1a2e;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.step-connector {
|
|
168
|
+
width: 3rem;
|
|
169
|
+
height: 2px;
|
|
170
|
+
background: #333;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.wizard-card {
|
|
174
|
+
background: var(--bg-card);
|
|
175
|
+
border-radius: 1rem;
|
|
176
|
+
padding: 2rem;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.wizard-card h3 {
|
|
180
|
+
margin-bottom: 1rem;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.help-text {
|
|
184
|
+
background: var(--bg);
|
|
185
|
+
border-radius: 0.5rem;
|
|
186
|
+
padding: 1rem;
|
|
187
|
+
margin-bottom: 1.5rem;
|
|
188
|
+
font-size: 0.875rem;
|
|
189
|
+
color: var(--text-dim);
|
|
190
|
+
line-height: 1.6;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.help-text a {
|
|
194
|
+
color: var(--accent);
|
|
195
|
+
text-decoration: none;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.help-text ol {
|
|
199
|
+
margin-left: 1.25rem;
|
|
200
|
+
margin-top: 0.5rem;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.help-text li {
|
|
204
|
+
margin-bottom: 0.25rem;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.form-group {
|
|
208
|
+
margin-bottom: 1rem;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.form-group label {
|
|
212
|
+
display: block;
|
|
213
|
+
margin-bottom: 0.5rem;
|
|
214
|
+
font-size: 0.875rem;
|
|
215
|
+
color: var(--text-dim);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.auth-display {
|
|
219
|
+
text-align: center;
|
|
220
|
+
padding: 1.5rem;
|
|
221
|
+
background: var(--bg);
|
|
222
|
+
border-radius: 0.5rem;
|
|
223
|
+
margin: 1.5rem 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.auth-display .url {
|
|
227
|
+
margin-bottom: 1rem;
|
|
228
|
+
word-break: break-all;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.auth-display .url a {
|
|
232
|
+
color: #58a6ff;
|
|
233
|
+
text-decoration: underline;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.auth-display .code {
|
|
237
|
+
font-size: 2.5rem;
|
|
238
|
+
font-weight: bold;
|
|
239
|
+
letter-spacing: 0.3em;
|
|
240
|
+
color: var(--warning);
|
|
241
|
+
font-family: monospace;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.auth-display .waiting {
|
|
245
|
+
margin-top: 1rem;
|
|
246
|
+
color: var(--text-dim);
|
|
247
|
+
font-size: 0.875rem;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.success-box {
|
|
251
|
+
background: rgba(74, 222, 128, 0.1);
|
|
252
|
+
border: 1px solid var(--success);
|
|
253
|
+
border-radius: 0.5rem;
|
|
254
|
+
padding: 1rem;
|
|
255
|
+
text-align: center;
|
|
256
|
+
color: var(--success);
|
|
257
|
+
margin-bottom: 1rem;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* Dashboard Grid */
|
|
261
|
+
.grid {
|
|
262
|
+
display: grid;
|
|
263
|
+
grid-template-columns: repeat(2, 1fr);
|
|
264
|
+
gap: 1rem;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@media (max-width: 700px) {
|
|
268
|
+
.grid {
|
|
269
|
+
grid-template-columns: 1fr;
|
|
270
|
+
}
|
|
271
|
+
.grid .card[style*="grid-column"] {
|
|
272
|
+
grid-column: 1 !important;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.card {
|
|
277
|
+
background: var(--bg-card);
|
|
278
|
+
border-radius: 1rem;
|
|
279
|
+
padding: 1.5rem;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.card h2 {
|
|
283
|
+
font-size: 1rem;
|
|
284
|
+
font-weight: 500;
|
|
285
|
+
color: var(--text-dim);
|
|
286
|
+
margin-bottom: 1rem;
|
|
287
|
+
text-transform: uppercase;
|
|
288
|
+
letter-spacing: 0.05em;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.card-content {
|
|
292
|
+
display: flex;
|
|
293
|
+
flex-direction: column;
|
|
294
|
+
gap: 0.75rem;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.row {
|
|
298
|
+
display: flex;
|
|
299
|
+
justify-content: space-between;
|
|
300
|
+
align-items: center;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.label { color: var(--text-dim); }
|
|
304
|
+
.value { font-weight: 500; }
|
|
305
|
+
.value.success { color: var(--success); }
|
|
306
|
+
.value.error { color: var(--error); }
|
|
307
|
+
|
|
308
|
+
input[type="text"],
|
|
309
|
+
input[type="password"],
|
|
310
|
+
input[type="url"] {
|
|
311
|
+
width: 100%;
|
|
312
|
+
padding: 0.75rem 1rem;
|
|
313
|
+
border: 1px solid #333;
|
|
314
|
+
border-radius: 0.5rem;
|
|
315
|
+
background: var(--bg);
|
|
316
|
+
color: var(--text);
|
|
317
|
+
font-size: 0.875rem;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
input:focus {
|
|
321
|
+
outline: none;
|
|
322
|
+
border-color: var(--accent);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
button {
|
|
326
|
+
padding: 0.75rem 1.5rem;
|
|
327
|
+
border: none;
|
|
328
|
+
border-radius: 0.5rem;
|
|
329
|
+
font-size: 0.875rem;
|
|
330
|
+
font-weight: 500;
|
|
331
|
+
cursor: pointer;
|
|
332
|
+
transition: opacity 0.2s;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
button:hover { opacity: 0.9; }
|
|
336
|
+
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
337
|
+
|
|
338
|
+
.btn-primary { background: var(--accent); color: white; }
|
|
339
|
+
.btn-secondary { background: #333; color: var(--text); }
|
|
340
|
+
.btn-success { background: var(--success); color: #1a1a2e; }
|
|
341
|
+
.btn-block { width: 100%; }
|
|
342
|
+
.btn-lg { padding: 1rem 2rem; font-size: 1rem; }
|
|
343
|
+
|
|
344
|
+
.toggle {
|
|
345
|
+
display: flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
gap: 0.75rem;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.toggle input[type="checkbox"] {
|
|
351
|
+
width: 3rem;
|
|
352
|
+
height: 1.5rem;
|
|
353
|
+
appearance: none;
|
|
354
|
+
background: #333;
|
|
355
|
+
border-radius: 1rem;
|
|
356
|
+
position: relative;
|
|
357
|
+
cursor: pointer;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.toggle input[type="checkbox"]::before {
|
|
361
|
+
content: '';
|
|
362
|
+
position: absolute;
|
|
363
|
+
top: 2px;
|
|
364
|
+
left: 2px;
|
|
365
|
+
width: 1.25rem;
|
|
366
|
+
height: 1.25rem;
|
|
367
|
+
background: var(--text-dim);
|
|
368
|
+
border-radius: 50%;
|
|
369
|
+
transition: all 0.2s;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.toggle input[type="checkbox"]:checked { background: var(--accent); }
|
|
373
|
+
.toggle input[type="checkbox"]:checked::before {
|
|
374
|
+
left: calc(100% - 1.25rem - 2px);
|
|
375
|
+
background: white;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.result-box {
|
|
379
|
+
background: var(--bg);
|
|
380
|
+
border-radius: 0.5rem;
|
|
381
|
+
padding: 1rem;
|
|
382
|
+
font-family: monospace;
|
|
383
|
+
font-size: 0.875rem;
|
|
384
|
+
max-height: 200px;
|
|
385
|
+
overflow-y: auto;
|
|
386
|
+
white-space: pre-wrap;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.console-box {
|
|
390
|
+
background: #0d1117;
|
|
391
|
+
border: 1px solid #333;
|
|
392
|
+
border-radius: 0.5rem;
|
|
393
|
+
padding: 1rem;
|
|
394
|
+
font-family: 'Consolas', 'Monaco', monospace;
|
|
395
|
+
font-size: 0.8rem;
|
|
396
|
+
line-height: 1.5;
|
|
397
|
+
overflow-y: auto;
|
|
398
|
+
white-space: pre-wrap;
|
|
399
|
+
color: #c9d1d9;
|
|
400
|
+
max-height: 350px;
|
|
401
|
+
min-height: 200px;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.console-box .log-info { color: #58a6ff; }
|
|
405
|
+
.console-box .log-success { color: #3fb950; }
|
|
406
|
+
.console-box .log-warning { color: #d29922; }
|
|
407
|
+
.console-box .log-error { color: #f85149; }
|
|
408
|
+
.console-box .log-dim { color: #6e7681; }
|
|
409
|
+
|
|
410
|
+
.progress-container {
|
|
411
|
+
margin-bottom: 0.75rem;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.progress-bar {
|
|
415
|
+
height: 0.5rem;
|
|
416
|
+
background: #333;
|
|
417
|
+
border-radius: 0.25rem;
|
|
418
|
+
overflow: hidden;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.progress-fill {
|
|
422
|
+
height: 100%;
|
|
423
|
+
background: var(--accent);
|
|
424
|
+
border-radius: 0.25rem;
|
|
425
|
+
transition: width 0.3s ease;
|
|
426
|
+
width: 0%;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.progress-fill.fetching {
|
|
430
|
+
animation: pulse-bar 1.5s ease-in-out infinite;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.progress-fill.task {
|
|
434
|
+
background: #d97706;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
@keyframes pulse-bar {
|
|
438
|
+
0%, 100% { opacity: 1; }
|
|
439
|
+
50% { opacity: 0.6; }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.progress-label {
|
|
443
|
+
display: flex;
|
|
444
|
+
justify-content: space-between;
|
|
445
|
+
font-size: 0.8rem;
|
|
446
|
+
color: var(--text);
|
|
447
|
+
margin-top: 0.25rem;
|
|
448
|
+
font-weight: 500;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.progress-label .activity {
|
|
452
|
+
color: var(--accent);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.progress-label .activity.fetching::after {
|
|
456
|
+
content: '...';
|
|
457
|
+
animation: dots 1.5s infinite;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.progress-label.task-label {
|
|
461
|
+
font-size: 0.75rem;
|
|
462
|
+
color: #9ca3af;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.progress-label.task-label .activity {
|
|
466
|
+
color: #d97706;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
@keyframes dots {
|
|
470
|
+
0%, 20% { content: '.'; }
|
|
471
|
+
40% { content: '..'; }
|
|
472
|
+
60%, 100% { content: '...'; }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.hidden { display: none !important; }
|
|
476
|
+
.mt-1 { margin-top: 0.5rem; }
|
|
477
|
+
.mt-2 { margin-top: 1rem; }
|
|
478
|
+
|
|
479
|
+
@keyframes pulse {
|
|
480
|
+
0%, 100% { opacity: 1; }
|
|
481
|
+
50% { opacity: 0.5; }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.syncing { animation: pulse 2s infinite; }
|
|
485
|
+
|
|
486
|
+
.btn-row {
|
|
487
|
+
display: flex;
|
|
488
|
+
gap: 1rem;
|
|
489
|
+
margin-top: 1.5rem;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.btn-row button {
|
|
493
|
+
flex: 1;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.icon-btn {
|
|
497
|
+
background: transparent;
|
|
498
|
+
border: 1px solid #333;
|
|
499
|
+
color: var(--text-dim);
|
|
500
|
+
width: 2.25rem;
|
|
501
|
+
height: 2.25rem;
|
|
502
|
+
border-radius: 0.5rem;
|
|
503
|
+
padding: 0;
|
|
504
|
+
display: flex;
|
|
505
|
+
align-items: center;
|
|
506
|
+
justify-content: center;
|
|
507
|
+
transition: all 0.2s;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.icon-btn:hover {
|
|
511
|
+
border-color: var(--text-dim);
|
|
512
|
+
color: var(--text);
|
|
513
|
+
background: var(--bg-card);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.icon-btn.danger:hover {
|
|
517
|
+
border-color: var(--error);
|
|
518
|
+
color: var(--error);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.icon-btn svg {
|
|
522
|
+
width: 1.125rem;
|
|
523
|
+
height: 1.125rem;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.header-right {
|
|
527
|
+
display: flex;
|
|
528
|
+
align-items: center;
|
|
529
|
+
gap: 0.5rem;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.view-container {
|
|
533
|
+
display: none;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.view-container.active {
|
|
537
|
+
display: block;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
.stats-grid {
|
|
541
|
+
display: grid;
|
|
542
|
+
grid-template-columns: repeat(4, 1fr);
|
|
543
|
+
gap: 1rem;
|
|
544
|
+
text-align: center;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
@media (max-width: 600px) {
|
|
548
|
+
.stats-grid {
|
|
549
|
+
grid-template-columns: repeat(2, 1fr);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
.stat-item {
|
|
554
|
+
padding: 1rem;
|
|
555
|
+
background: var(--bg);
|
|
556
|
+
border-radius: 0.5rem;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
.stat-value {
|
|
560
|
+
font-size: 2rem;
|
|
561
|
+
font-weight: 600;
|
|
562
|
+
color: var(--accent);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
.stat-label {
|
|
566
|
+
font-size: 0.75rem;
|
|
567
|
+
color: var(--text-dim);
|
|
568
|
+
text-transform: uppercase;
|
|
569
|
+
letter-spacing: 0.05em;
|
|
570
|
+
margin-top: 0.25rem;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/* Modal */
|
|
574
|
+
.modal-overlay {
|
|
575
|
+
position: fixed;
|
|
576
|
+
inset: 0;
|
|
577
|
+
background: rgba(0, 0, 0, 0.7);
|
|
578
|
+
display: flex;
|
|
579
|
+
align-items: center;
|
|
580
|
+
justify-content: center;
|
|
581
|
+
z-index: 1000;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.modal {
|
|
585
|
+
background: var(--bg-card);
|
|
586
|
+
border-radius: 1rem;
|
|
587
|
+
padding: 1.5rem;
|
|
588
|
+
max-width: 400px;
|
|
589
|
+
width: 90%;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.modal h3 {
|
|
593
|
+
margin-bottom: 0.75rem;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.modal p {
|
|
597
|
+
color: var(--text-dim);
|
|
598
|
+
font-size: 0.875rem;
|
|
599
|
+
line-height: 1.5;
|
|
600
|
+
margin-bottom: 1.5rem;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
.modal-buttons {
|
|
604
|
+
display: flex;
|
|
605
|
+
gap: 0.75rem;
|
|
606
|
+
justify-content: flex-end;
|
|
607
|
+
}
|
|
608
|
+
</style>
|
|
609
|
+
</head>
|
|
610
|
+
<body>
|
|
611
|
+
<div class="container">
|
|
612
|
+
<header>
|
|
613
|
+
<h1><img src="/assets/logo.png" alt="Pakt" class="header-logo"></h1>
|
|
614
|
+
<div id="main-nav" class="nav-tabs hidden">
|
|
615
|
+
<button class="nav-tab active" onclick="showView('sync')">Sync</button>
|
|
616
|
+
<button class="nav-tab" onclick="showView('stats')">Stats</button>
|
|
617
|
+
</div>
|
|
618
|
+
<div class="header-right">
|
|
619
|
+
<div id="status-badge" class="status-badge ok">Loading...</div>
|
|
620
|
+
<button id="settings-btn" class="icon-btn hidden" onclick="showView('settings')" title="Settings">
|
|
621
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
622
|
+
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
|
623
|
+
<circle cx="12" cy="12" r="3"/>
|
|
624
|
+
</svg>
|
|
625
|
+
</button>
|
|
626
|
+
<button class="icon-btn danger" onclick="shutdownServer()" title="Shutdown server">
|
|
627
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
628
|
+
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
|
|
629
|
+
<line x1="12" y1="2" x2="12" y2="12"/>
|
|
630
|
+
</svg>
|
|
631
|
+
</button>
|
|
632
|
+
</div>
|
|
633
|
+
</header>
|
|
634
|
+
|
|
635
|
+
<!-- Setup Wizard -->
|
|
636
|
+
<div id="wizard" class="wizard hidden">
|
|
637
|
+
<div class="wizard-header">
|
|
638
|
+
<h2>Welcome to Pakt</h2>
|
|
639
|
+
<p>Let's get you set up in just a few steps</p>
|
|
640
|
+
</div>
|
|
641
|
+
|
|
642
|
+
<div class="steps">
|
|
643
|
+
<div id="step1-indicator" class="step active">
|
|
644
|
+
<div class="step-number">1</div>
|
|
645
|
+
<span>Trakt</span>
|
|
646
|
+
</div>
|
|
647
|
+
<div class="step-connector"></div>
|
|
648
|
+
<div id="step2-indicator" class="step">
|
|
649
|
+
<div class="step-number">2</div>
|
|
650
|
+
<span>Plex</span>
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
<!-- Step 1: Trakt -->
|
|
655
|
+
<div id="step1" class="wizard-card">
|
|
656
|
+
<h3>Connect to Trakt</h3>
|
|
657
|
+
|
|
658
|
+
<div id="step1-start">
|
|
659
|
+
<p style="color: var(--text-dim); margin-bottom: 1.5rem;">
|
|
660
|
+
Click below to link your Trakt account. You'll be given a code to enter on trakt.tv.
|
|
661
|
+
</p>
|
|
662
|
+
<button class="btn-primary btn-block btn-lg" onclick="startTraktAuth()">
|
|
663
|
+
Connect to Trakt
|
|
664
|
+
</button>
|
|
665
|
+
</div>
|
|
666
|
+
|
|
667
|
+
<div id="step1-auth" class="hidden">
|
|
668
|
+
<div class="auth-display">
|
|
669
|
+
<div class="url">Go to: <a id="auth-url" href="" target="_blank"></a></div>
|
|
670
|
+
<div class="code" id="auth-code"></div>
|
|
671
|
+
<div class="waiting syncing">Waiting for authorization...</div>
|
|
672
|
+
</div>
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
<div id="step1-done" class="hidden">
|
|
676
|
+
<div class="success-box">
|
|
677
|
+
Trakt connected successfully!
|
|
678
|
+
</div>
|
|
679
|
+
<button class="btn-success btn-block btn-lg" onclick="goToStep2()">
|
|
680
|
+
Continue to Plex Setup
|
|
681
|
+
</button>
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
<!-- Step 2: Plex -->
|
|
686
|
+
<div id="step2" class="wizard-card hidden">
|
|
687
|
+
<h3>Connect to Plex</h3>
|
|
688
|
+
|
|
689
|
+
<div id="step2-start">
|
|
690
|
+
<p style="color: var(--text-dim); margin-bottom: 1.5rem;">
|
|
691
|
+
Link your Plex account to automatically discover your servers.
|
|
692
|
+
</p>
|
|
693
|
+
<button class="btn-primary btn-block btn-lg" onclick="startPlexAuth()">
|
|
694
|
+
Link Plex Account
|
|
695
|
+
</button>
|
|
696
|
+
<button class="btn-secondary btn-block mt-1" onclick="showManualPlexSetup()">
|
|
697
|
+
Enter token manually
|
|
698
|
+
</button>
|
|
699
|
+
</div>
|
|
700
|
+
|
|
701
|
+
<div id="step2-auth" class="hidden">
|
|
702
|
+
<div class="auth-display">
|
|
703
|
+
<div class="url">Go to: <a href="https://plex.tv/link" target="_blank">https://plex.tv/link</a></div>
|
|
704
|
+
<div class="code" id="plex-auth-code"></div>
|
|
705
|
+
<div class="waiting syncing">Waiting for authorization...</div>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
|
|
709
|
+
<div id="step2-servers" class="hidden">
|
|
710
|
+
<div class="success-box" style="margin-bottom: 1rem;">
|
|
711
|
+
Plex account linked!
|
|
712
|
+
</div>
|
|
713
|
+
<h4 style="margin-bottom: 0.75rem;">Select servers to sync:</h4>
|
|
714
|
+
<div id="discovered-servers" style="margin-bottom: 1rem;"></div>
|
|
715
|
+
<button class="btn-success btn-block btn-lg" onclick="addSelectedServers()">
|
|
716
|
+
Continue
|
|
717
|
+
</button>
|
|
718
|
+
</div>
|
|
719
|
+
|
|
720
|
+
<div id="step2-manual" class="hidden">
|
|
721
|
+
<div class="help-text">
|
|
722
|
+
<strong>To find your Plex token:</strong>
|
|
723
|
+
<ol>
|
|
724
|
+
<li>Open Plex Web App and sign in</li>
|
|
725
|
+
<li>Open any media item</li>
|
|
726
|
+
<li>Click the three dots (...) menu</li>
|
|
727
|
+
<li>Select "Get Info" then "View XML"</li>
|
|
728
|
+
<li>In the URL, find <code>X-Plex-Token=xxxxx</code></li>
|
|
729
|
+
<li>Copy just the token part after the <code>=</code></li>
|
|
730
|
+
</ol>
|
|
731
|
+
</div>
|
|
732
|
+
|
|
733
|
+
<div class="form-group">
|
|
734
|
+
<label>Plex Server URL</label>
|
|
735
|
+
<input type="url" id="plex-url" placeholder="http://localhost:32400">
|
|
736
|
+
</div>
|
|
737
|
+
<div class="form-group">
|
|
738
|
+
<label>Plex Token</label>
|
|
739
|
+
<input type="text" id="plex-token" placeholder="e.g. abc123XYZ..." autocomplete="off" spellcheck="false">
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<button class="btn-primary btn-block btn-lg" onclick="testAndSavePlex()">
|
|
743
|
+
Connect to Plex
|
|
744
|
+
</button>
|
|
745
|
+
<button class="btn-secondary btn-block mt-1" onclick="cancelManualPlexSetup()">
|
|
746
|
+
Back to PIN login
|
|
747
|
+
</button>
|
|
748
|
+
<div id="plex-test-result" class="result-box mt-2 hidden"></div>
|
|
749
|
+
</div>
|
|
750
|
+
|
|
751
|
+
<div id="step2-done" class="hidden">
|
|
752
|
+
<div class="success-box">
|
|
753
|
+
<div id="plex-setup-result">Plex connected!</div>
|
|
754
|
+
</div>
|
|
755
|
+
<button class="btn-success btn-block btn-lg" onclick="finishSetup()">
|
|
756
|
+
Start Using Pakt
|
|
757
|
+
</button>
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
</div>
|
|
761
|
+
|
|
762
|
+
<!-- Main Dashboard -->
|
|
763
|
+
<div id="dashboard" class="hidden">
|
|
764
|
+
|
|
765
|
+
<!-- SYNC VIEW -->
|
|
766
|
+
<div id="view-sync" class="view-container active">
|
|
767
|
+
<div class="grid">
|
|
768
|
+
<!-- Sync Actions -->
|
|
769
|
+
<div class="card">
|
|
770
|
+
<h2>Run Sync</h2>
|
|
771
|
+
<div class="card-content">
|
|
772
|
+
<button id="sync-btn" class="btn-primary btn-block btn-lg" onclick="startSync(false)">
|
|
773
|
+
Start Sync
|
|
774
|
+
</button>
|
|
775
|
+
<button id="dry-run-btn" class="btn-secondary btn-block" onclick="startSync(true)">
|
|
776
|
+
Dry Run (Preview)
|
|
777
|
+
</button>
|
|
778
|
+
<button id="cancel-btn" class="btn-secondary btn-block hidden" style="background: var(--error);" onclick="cancelSync()">
|
|
779
|
+
Cancel
|
|
780
|
+
</button>
|
|
781
|
+
<label class="toggle" style="margin-top: 0.75rem;">
|
|
782
|
+
<input type="checkbox" id="verbose-logging">
|
|
783
|
+
<span>Verbose (show items)</span>
|
|
784
|
+
</label>
|
|
785
|
+
</div>
|
|
786
|
+
</div>
|
|
787
|
+
|
|
788
|
+
<!-- Sync Options -->
|
|
789
|
+
<div class="card">
|
|
790
|
+
<h2>Default Sync Options</h2>
|
|
791
|
+
<div class="card-content">
|
|
792
|
+
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
|
|
793
|
+
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
|
794
|
+
<div style="font-size: 0.75rem; color: var(--text-dim);">Plex → Trakt</div>
|
|
795
|
+
<label class="toggle">
|
|
796
|
+
<input type="checkbox" id="watched-plex-to-trakt" checked onchange="saveSyncOptions()">
|
|
797
|
+
<span>Watched</span>
|
|
798
|
+
</label>
|
|
799
|
+
<label class="toggle">
|
|
800
|
+
<input type="checkbox" id="ratings-plex-to-trakt" checked onchange="saveSyncOptions()">
|
|
801
|
+
<span>Ratings</span>
|
|
802
|
+
</label>
|
|
803
|
+
<label class="toggle">
|
|
804
|
+
<input type="checkbox" id="collection-plex-to-trakt" onchange="saveSyncOptions()">
|
|
805
|
+
<span>Collection</span>
|
|
806
|
+
</label>
|
|
807
|
+
<label class="toggle">
|
|
808
|
+
<input type="checkbox" id="watchlist-plex-to-trakt" onchange="saveSyncOptions()">
|
|
809
|
+
<span>Watchlist</span>
|
|
810
|
+
</label>
|
|
811
|
+
</div>
|
|
812
|
+
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
|
813
|
+
<div style="font-size: 0.75rem; color: var(--text-dim);">Trakt → Plex</div>
|
|
814
|
+
<label class="toggle">
|
|
815
|
+
<input type="checkbox" id="watched-trakt-to-plex" checked onchange="saveSyncOptions()">
|
|
816
|
+
<span>Watched</span>
|
|
817
|
+
</label>
|
|
818
|
+
<label class="toggle">
|
|
819
|
+
<input type="checkbox" id="ratings-trakt-to-plex" checked onchange="saveSyncOptions()">
|
|
820
|
+
<span>Ratings</span>
|
|
821
|
+
</label>
|
|
822
|
+
<label class="toggle">
|
|
823
|
+
<input type="checkbox" id="watchlist-trakt-to-plex" onchange="saveSyncOptions()">
|
|
824
|
+
<span>Watchlist</span>
|
|
825
|
+
</label>
|
|
826
|
+
</div>
|
|
827
|
+
</div>
|
|
828
|
+
<div id="sync-options-status" class="mt-1" style="font-size: 0.8rem; color: var(--text-dim);"></div>
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
|
|
832
|
+
<!-- Console Output - full width -->
|
|
833
|
+
<div class="card" style="grid-column: 1 / -1;">
|
|
834
|
+
<h2 style="display: flex; justify-content: space-between; align-items: center;">
|
|
835
|
+
Console
|
|
836
|
+
<button class="btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="toggleConsoleDetails()">
|
|
837
|
+
<span id="details-toggle-text">Show Details</span>
|
|
838
|
+
</button>
|
|
839
|
+
</h2>
|
|
840
|
+
<div class="card-content">
|
|
841
|
+
<div id="progress-container" class="progress-container hidden">
|
|
842
|
+
<div class="progress-label" style="margin-bottom: 0.25rem;">
|
|
843
|
+
<span><span id="progress-phase">Phase 1/4</span> - <span id="progress-activity" class="activity">Fetching data</span></span>
|
|
844
|
+
<span id="progress-percent">0%</span>
|
|
845
|
+
</div>
|
|
846
|
+
<div class="progress-bar">
|
|
847
|
+
<div id="progress-fill" class="progress-fill"></div>
|
|
848
|
+
</div>
|
|
849
|
+
<div id="task-progress-container" class="hidden" style="margin-top: 0.5rem;">
|
|
850
|
+
<div class="progress-label task-label" style="margin-bottom: 0.25rem;">
|
|
851
|
+
<span id="task-activity" class="activity">Processing</span>
|
|
852
|
+
<span id="task-percent">0%</span>
|
|
853
|
+
</div>
|
|
854
|
+
<div class="progress-bar">
|
|
855
|
+
<div id="task-progress-fill" class="progress-fill task"></div>
|
|
856
|
+
</div>
|
|
857
|
+
</div>
|
|
858
|
+
</div>
|
|
859
|
+
<div id="console-output" class="console-box">Waiting for sync...</div>
|
|
860
|
+
<div id="console-details" class="console-box mt-1 hidden" style="max-height: 300px;"></div>
|
|
861
|
+
</div>
|
|
862
|
+
</div>
|
|
863
|
+
</div>
|
|
864
|
+
</div>
|
|
865
|
+
|
|
866
|
+
<!-- STATS VIEW -->
|
|
867
|
+
<div id="view-stats" class="view-container">
|
|
868
|
+
<div class="card">
|
|
869
|
+
<h2>Last Sync Results</h2>
|
|
870
|
+
<div class="card-content stats-grid">
|
|
871
|
+
<div class="stat-item">
|
|
872
|
+
<div class="stat-value" id="added-trakt">-</div>
|
|
873
|
+
<div class="stat-label">Added to Trakt</div>
|
|
874
|
+
</div>
|
|
875
|
+
<div class="stat-item">
|
|
876
|
+
<div class="stat-value" id="added-plex">-</div>
|
|
877
|
+
<div class="stat-label">Added to Plex</div>
|
|
878
|
+
</div>
|
|
879
|
+
<div class="stat-item">
|
|
880
|
+
<div class="stat-value" id="ratings-synced">-</div>
|
|
881
|
+
<div class="stat-label">Ratings Synced</div>
|
|
882
|
+
</div>
|
|
883
|
+
<div class="stat-item">
|
|
884
|
+
<div class="stat-value" id="last-duration">-</div>
|
|
885
|
+
<div class="stat-label">Duration</div>
|
|
886
|
+
</div>
|
|
887
|
+
</div>
|
|
888
|
+
<div class="row mt-2" style="justify-content: center;">
|
|
889
|
+
<span class="label">Last run:</span>
|
|
890
|
+
<span id="last-run" class="value" style="margin-left: 0.5rem;">Never</span>
|
|
891
|
+
</div>
|
|
892
|
+
</div>
|
|
893
|
+
|
|
894
|
+
<div class="card mt-2">
|
|
895
|
+
<h2>Trakt Account</h2>
|
|
896
|
+
<div class="card-content">
|
|
897
|
+
<div class="row">
|
|
898
|
+
<span class="label">Status</span>
|
|
899
|
+
<span id="trakt-vip-status" class="value">-</span>
|
|
900
|
+
</div>
|
|
901
|
+
<div class="row">
|
|
902
|
+
<span class="label">Collection Limit</span>
|
|
903
|
+
<span id="trakt-collection-limit" class="value">-</span>
|
|
904
|
+
</div>
|
|
905
|
+
<div class="row">
|
|
906
|
+
<span class="label">Watchlist Limit</span>
|
|
907
|
+
<span id="trakt-watchlist-limit" class="value">-</span>
|
|
908
|
+
</div>
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
|
|
912
|
+
<div class="card mt-2">
|
|
913
|
+
<h2>Plex Servers</h2>
|
|
914
|
+
<div id="plex-servers-stats" class="card-content">
|
|
915
|
+
<span style="color: var(--text-dim);">Loading...</span>
|
|
916
|
+
</div>
|
|
917
|
+
</div>
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
<!-- SETTINGS VIEW -->
|
|
921
|
+
<div id="view-settings" class="view-container">
|
|
922
|
+
<div class="grid">
|
|
923
|
+
<div class="card">
|
|
924
|
+
<h2>Connections</h2>
|
|
925
|
+
<div class="card-content">
|
|
926
|
+
<div class="row">
|
|
927
|
+
<span class="label">Trakt</span>
|
|
928
|
+
<span id="trakt-status" class="value success">Connected</span>
|
|
929
|
+
</div>
|
|
930
|
+
<div class="row">
|
|
931
|
+
<span class="label">Plex</span>
|
|
932
|
+
<span id="plex-status" class="value success">Connected</span>
|
|
933
|
+
</div>
|
|
934
|
+
<button class="btn-secondary btn-block mt-2" onclick="showReconfigure()">
|
|
935
|
+
Reconfigure
|
|
936
|
+
</button>
|
|
937
|
+
</div>
|
|
938
|
+
</div>
|
|
939
|
+
|
|
940
|
+
<div class="card">
|
|
941
|
+
<h2>About</h2>
|
|
942
|
+
<div class="card-content">
|
|
943
|
+
<div class="row">
|
|
944
|
+
<span class="label">Version</span>
|
|
945
|
+
<span class="value">0.1.0</span>
|
|
946
|
+
</div>
|
|
947
|
+
<div class="row">
|
|
948
|
+
<span class="label">Config</span>
|
|
949
|
+
<span class="value" style="font-size: 0.75rem;">{{ config_dir }}</span>
|
|
950
|
+
</div>
|
|
951
|
+
</div>
|
|
952
|
+
</div>
|
|
953
|
+
|
|
954
|
+
<!-- Servers - full width -->
|
|
955
|
+
<div class="card" style="grid-column: 1 / -1;">
|
|
956
|
+
<h2>Servers</h2>
|
|
957
|
+
<div class="card-content">
|
|
958
|
+
<p style="color: var(--text-dim); font-size: 0.875rem; margin-bottom: 1rem;">
|
|
959
|
+
Manage your Plex servers for sync.
|
|
960
|
+
</p>
|
|
961
|
+
<div id="servers-list" style="margin-bottom: 1rem;"></div>
|
|
962
|
+
<div style="display: flex; gap: 0.5rem;">
|
|
963
|
+
<button class="btn-secondary" onclick="refreshServersInSettings()">Refresh</button>
|
|
964
|
+
<button class="btn-primary" onclick="startPlexReauth()">Re-Link Account</button>
|
|
965
|
+
</div>
|
|
966
|
+
<div id="servers-reauth" class="hidden mt-2">
|
|
967
|
+
<div class="auth-display" style="padding: 1rem;">
|
|
968
|
+
<div class="url">Go to: <a href="https://plex.tv/link" target="_blank">https://plex.tv/link</a></div>
|
|
969
|
+
<div class="code" id="reauth-pin-code" style="font-size: 1.5rem;"></div>
|
|
970
|
+
<div class="waiting syncing" style="font-size: 0.875rem;">Waiting...</div>
|
|
971
|
+
</div>
|
|
972
|
+
</div>
|
|
973
|
+
</div>
|
|
974
|
+
</div>
|
|
975
|
+
|
|
976
|
+
<!-- Scheduler - full width -->
|
|
977
|
+
<div class="card" style="grid-column: 1 / -1;">
|
|
978
|
+
<h2>Scheduler</h2>
|
|
979
|
+
<div class="card-content">
|
|
980
|
+
<p style="color: var(--text-dim); font-size: 0.875rem; margin-bottom: 1rem;">
|
|
981
|
+
Automatically sync on a schedule while the server is running.
|
|
982
|
+
</p>
|
|
983
|
+
<div class="grid" style="gap: 1rem; align-items: center;">
|
|
984
|
+
<div>
|
|
985
|
+
<label class="toggle">
|
|
986
|
+
<input type="checkbox" id="scheduler-enabled" onchange="saveSchedulerOptions()">
|
|
987
|
+
<span>Enable scheduler</span>
|
|
988
|
+
</label>
|
|
989
|
+
</div>
|
|
990
|
+
<div>
|
|
991
|
+
<div class="form-group" style="margin: 0;">
|
|
992
|
+
<label style="margin-bottom: 0.25rem;">Interval (hours)</label>
|
|
993
|
+
<input type="number" id="scheduler-interval" min="1" max="168" value="6" style="width: 100px;" onchange="saveSchedulerOptions()">
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
</div>
|
|
997
|
+
<div id="scheduler-status" class="mt-2" style="font-size: 0.8rem; color: var(--text-dim);"></div>
|
|
998
|
+
</div>
|
|
999
|
+
</div>
|
|
1000
|
+
|
|
1001
|
+
<!-- Per-Server Settings - full width -->
|
|
1002
|
+
<div class="card" style="grid-column: 1 / -1;">
|
|
1003
|
+
<h2>Server Settings</h2>
|
|
1004
|
+
<div class="card-content">
|
|
1005
|
+
<p style="color: var(--text-dim); font-size: 0.875rem; margin-bottom: 1rem;">
|
|
1006
|
+
Configure libraries and sync options per server. Sync options default to global settings above.
|
|
1007
|
+
</p>
|
|
1008
|
+
<div id="libraries-loading" style="color: var(--text-dim);">Loading libraries...</div>
|
|
1009
|
+
<div id="libraries-container" class="hidden"></div>
|
|
1010
|
+
<div id="libraries-error" class="hidden" style="color: var(--error);"></div>
|
|
1011
|
+
</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
</div>
|
|
1015
|
+
|
|
1016
|
+
</div>
|
|
1017
|
+
|
|
1018
|
+
<!-- Reconfigure Modal -->
|
|
1019
|
+
<div id="reconfigure-modal" class="modal-overlay hidden">
|
|
1020
|
+
<div class="modal">
|
|
1021
|
+
<h3>Reconfigure Connections</h3>
|
|
1022
|
+
<p>
|
|
1023
|
+
This will start the setup wizard to re-link your Trakt and Plex accounts.
|
|
1024
|
+
Your current connections will remain active until you complete the new setup.
|
|
1025
|
+
</p>
|
|
1026
|
+
<div class="modal-buttons">
|
|
1027
|
+
<button class="btn-secondary" onclick="closeModal()">Cancel</button>
|
|
1028
|
+
<button class="btn-primary" onclick="confirmReconfigure()">Continue</button>
|
|
1029
|
+
</div>
|
|
1030
|
+
</div>
|
|
1031
|
+
</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
|
|
1034
|
+
<script>
|
|
1035
|
+
let isConfigured = false;
|
|
1036
|
+
let syncPolling = false;
|
|
1037
|
+
let currentView = 'sync';
|
|
1038
|
+
let wizardInProgress = false; // Prevents auto-switch to dashboard during setup
|
|
1039
|
+
|
|
1040
|
+
function showView(viewName) {
|
|
1041
|
+
currentView = viewName;
|
|
1042
|
+
|
|
1043
|
+
// Update view containers
|
|
1044
|
+
document.querySelectorAll('.view-container').forEach(v => v.classList.remove('active'));
|
|
1045
|
+
document.getElementById('view-' + viewName).classList.add('active');
|
|
1046
|
+
|
|
1047
|
+
// Update nav tabs
|
|
1048
|
+
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
|
1049
|
+
document.querySelectorAll('.nav-tab').forEach(t => {
|
|
1050
|
+
if (t.textContent.toLowerCase() === viewName) {
|
|
1051
|
+
t.classList.add('active');
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// Settings is accessed via icon, not tab
|
|
1056
|
+
if (viewName === 'settings') {
|
|
1057
|
+
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
|
1058
|
+
loadLibraries();
|
|
1059
|
+
refreshServersInSettings();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async function loadStatus() {
|
|
1064
|
+
try {
|
|
1065
|
+
const resp = await fetch('/api/status');
|
|
1066
|
+
const data = await resp.json();
|
|
1067
|
+
|
|
1068
|
+
isConfigured = data.trakt_authenticated && data.plex_configured;
|
|
1069
|
+
|
|
1070
|
+
// Update badge and sync UI state
|
|
1071
|
+
const badge = document.getElementById('status-badge');
|
|
1072
|
+
if (data.sync_running) {
|
|
1073
|
+
badge.textContent = 'Syncing...';
|
|
1074
|
+
badge.className = 'status-badge running';
|
|
1075
|
+
// Resume sync UI state after page refresh
|
|
1076
|
+
document.getElementById('sync-btn').disabled = true;
|
|
1077
|
+
document.getElementById('sync-btn').textContent = 'Syncing...';
|
|
1078
|
+
document.getElementById('dry-run-btn').classList.add('hidden');
|
|
1079
|
+
document.getElementById('cancel-btn').classList.remove('hidden');
|
|
1080
|
+
document.getElementById('verbose-logging').disabled = true;
|
|
1081
|
+
document.getElementById('progress-container').classList.remove('hidden');
|
|
1082
|
+
// Start polling if not already
|
|
1083
|
+
if (!syncPolling) {
|
|
1084
|
+
syncPolling = true;
|
|
1085
|
+
pollSyncStatus();
|
|
1086
|
+
}
|
|
1087
|
+
} else if (isConfigured) {
|
|
1088
|
+
badge.textContent = 'Ready';
|
|
1089
|
+
badge.className = 'status-badge ok';
|
|
1090
|
+
} else {
|
|
1091
|
+
badge.textContent = 'Setup';
|
|
1092
|
+
badge.className = 'status-badge error';
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Show wizard or dashboard
|
|
1096
|
+
if (isConfigured && !wizardInProgress) {
|
|
1097
|
+
document.getElementById('wizard').classList.add('hidden');
|
|
1098
|
+
document.getElementById('dashboard').classList.remove('hidden');
|
|
1099
|
+
document.getElementById('main-nav').classList.remove('hidden');
|
|
1100
|
+
document.getElementById('settings-btn').classList.remove('hidden');
|
|
1101
|
+
} else if (!isConfigured) {
|
|
1102
|
+
wizardInProgress = true;
|
|
1103
|
+
document.getElementById('wizard').classList.remove('hidden');
|
|
1104
|
+
document.getElementById('dashboard').classList.add('hidden');
|
|
1105
|
+
document.getElementById('main-nav').classList.add('hidden');
|
|
1106
|
+
document.getElementById('settings-btn').classList.add('hidden');
|
|
1107
|
+
|
|
1108
|
+
// Determine which step
|
|
1109
|
+
if (data.trakt_authenticated) {
|
|
1110
|
+
goToStep2();
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Update dashboard values
|
|
1115
|
+
if (isConfigured) {
|
|
1116
|
+
document.getElementById('trakt-status').textContent = 'Connected';
|
|
1117
|
+
document.getElementById('trakt-status').className = 'value success';
|
|
1118
|
+
document.getElementById('plex-status').textContent = 'Connected';
|
|
1119
|
+
document.getElementById('plex-status').className = 'value success';
|
|
1120
|
+
|
|
1121
|
+
// Load Trakt account info
|
|
1122
|
+
loadTraktAccount();
|
|
1123
|
+
// Load Plex server info
|
|
1124
|
+
loadPlexServer();
|
|
1125
|
+
|
|
1126
|
+
if (data.last_run) {
|
|
1127
|
+
document.getElementById('last-run').textContent = new Date(data.last_run).toLocaleString();
|
|
1128
|
+
}
|
|
1129
|
+
if (data.last_result) {
|
|
1130
|
+
document.getElementById('added-trakt').textContent = data.last_result.added_to_trakt || 0;
|
|
1131
|
+
document.getElementById('added-plex').textContent = data.last_result.added_to_plex || 0;
|
|
1132
|
+
document.getElementById('ratings-synced').textContent = data.last_result.ratings_synced || 0;
|
|
1133
|
+
if (data.last_result.duration) {
|
|
1134
|
+
document.getElementById('last-duration').textContent = data.last_result.duration.toFixed(1) + 's';
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
console.error('Failed to load status:', e);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
async function loadConfig() {
|
|
1144
|
+
try {
|
|
1145
|
+
const resp = await fetch('/api/config');
|
|
1146
|
+
const data = await resp.json();
|
|
1147
|
+
|
|
1148
|
+
document.getElementById('watched-plex-to-trakt').checked = data.sync?.watched_plex_to_trakt ?? true;
|
|
1149
|
+
document.getElementById('watched-trakt-to-plex').checked = data.sync?.watched_trakt_to_plex ?? true;
|
|
1150
|
+
document.getElementById('ratings-plex-to-trakt').checked = data.sync?.ratings_plex_to_trakt ?? true;
|
|
1151
|
+
document.getElementById('ratings-trakt-to-plex').checked = data.sync?.ratings_trakt_to_plex ?? true;
|
|
1152
|
+
document.getElementById('collection-plex-to-trakt').checked = data.sync?.collection_plex_to_trakt ?? false;
|
|
1153
|
+
document.getElementById('watchlist-plex-to-trakt').checked = data.sync?.watchlist_plex_to_trakt ?? false;
|
|
1154
|
+
document.getElementById('watchlist-trakt-to-plex').checked = data.sync?.watchlist_trakt_to_plex ?? false;
|
|
1155
|
+
document.getElementById('scheduler-enabled').checked = data.scheduler?.enabled ?? false;
|
|
1156
|
+
document.getElementById('scheduler-interval').value = data.scheduler?.interval_hours || 6;
|
|
1157
|
+
} catch (e) {
|
|
1158
|
+
console.error('Failed to load config:', e);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Wizard functions
|
|
1163
|
+
async function startTraktAuth() {
|
|
1164
|
+
document.getElementById('step1-start').classList.add('hidden');
|
|
1165
|
+
document.getElementById('step1-auth').classList.remove('hidden');
|
|
1166
|
+
|
|
1167
|
+
try {
|
|
1168
|
+
const resp = await fetch('/api/trakt/auth');
|
|
1169
|
+
const data = await resp.json();
|
|
1170
|
+
|
|
1171
|
+
if (data.error) {
|
|
1172
|
+
alert(data.error);
|
|
1173
|
+
document.getElementById('step1-start').classList.remove('hidden');
|
|
1174
|
+
document.getElementById('step1-auth').classList.add('hidden');
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
document.getElementById('auth-url').href = data.verification_url;
|
|
1179
|
+
document.getElementById('auth-url').textContent = data.verification_url;
|
|
1180
|
+
document.getElementById('auth-code').textContent = data.user_code;
|
|
1181
|
+
|
|
1182
|
+
// Poll for completion
|
|
1183
|
+
const pollAuth = async () => {
|
|
1184
|
+
const pollResp = await fetch('/api/trakt/auth/poll?device_code=' + data.device_code, {method: 'POST'});
|
|
1185
|
+
const pollData = await pollResp.json();
|
|
1186
|
+
|
|
1187
|
+
if (pollData.status === 'authenticated') {
|
|
1188
|
+
document.getElementById('step1-auth').classList.add('hidden');
|
|
1189
|
+
document.getElementById('step1-done').classList.remove('hidden');
|
|
1190
|
+
} else if (pollData.status === 'pending') {
|
|
1191
|
+
setTimeout(pollAuth, data.interval * 1000);
|
|
1192
|
+
} else {
|
|
1193
|
+
document.getElementById('step1-start').classList.remove('hidden');
|
|
1194
|
+
document.getElementById('step1-auth').classList.add('hidden');
|
|
1195
|
+
alert(pollData.message || 'Authentication failed');
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
setTimeout(pollAuth, data.interval * 1000);
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
alert('Error: ' + e.message);
|
|
1202
|
+
document.getElementById('step1-start').classList.remove('hidden');
|
|
1203
|
+
document.getElementById('step1-auth').classList.add('hidden');
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function goToStep2() {
|
|
1208
|
+
document.getElementById('step1').classList.add('hidden');
|
|
1209
|
+
document.getElementById('step2').classList.remove('hidden');
|
|
1210
|
+
document.getElementById('step1-indicator').classList.remove('active');
|
|
1211
|
+
document.getElementById('step1-indicator').classList.add('done');
|
|
1212
|
+
document.getElementById('step2-indicator').classList.add('active');
|
|
1213
|
+
|
|
1214
|
+
// Check if already configured
|
|
1215
|
+
tryDetectPlex();
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
async function tryDetectPlex() {
|
|
1219
|
+
try {
|
|
1220
|
+
// Check if servers are configured
|
|
1221
|
+
const serversResp = await fetch('/api/servers');
|
|
1222
|
+
const serversData = await serversResp.json();
|
|
1223
|
+
if (serversData.servers && serversData.servers.length > 0) {
|
|
1224
|
+
document.getElementById('step2-start').classList.add('hidden');
|
|
1225
|
+
document.getElementById('step2-done').classList.remove('hidden');
|
|
1226
|
+
document.getElementById('plex-setup-result').textContent =
|
|
1227
|
+
`${serversData.servers.length} server(s) configured`;
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Check legacy config
|
|
1232
|
+
const resp = await fetch('/api/plex/test', {method: 'POST'});
|
|
1233
|
+
const data = await resp.json();
|
|
1234
|
+
if (data.status === 'ok') {
|
|
1235
|
+
document.getElementById('step2-start').classList.add('hidden');
|
|
1236
|
+
document.getElementById('step2-done').classList.remove('hidden');
|
|
1237
|
+
document.getElementById('plex-setup-result').textContent =
|
|
1238
|
+
`Connected to ${data.server_name}`;
|
|
1239
|
+
}
|
|
1240
|
+
} catch (e) {
|
|
1241
|
+
// Not configured, show start screen
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
let plexPinId = null;
|
|
1246
|
+
let discoveredServers = [];
|
|
1247
|
+
|
|
1248
|
+
async function startPlexAuth() {
|
|
1249
|
+
document.getElementById('step2-start').classList.add('hidden');
|
|
1250
|
+
document.getElementById('step2-auth').classList.remove('hidden');
|
|
1251
|
+
|
|
1252
|
+
try {
|
|
1253
|
+
const resp = await fetch('/api/plex/pin', {method: 'POST'});
|
|
1254
|
+
const data = await resp.json();
|
|
1255
|
+
|
|
1256
|
+
if (data.status !== 'ok') {
|
|
1257
|
+
alert(data.message || 'Failed to start PIN login');
|
|
1258
|
+
document.getElementById('step2-start').classList.remove('hidden');
|
|
1259
|
+
document.getElementById('step2-auth').classList.add('hidden');
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
document.getElementById('plex-auth-code').textContent = data.pin;
|
|
1264
|
+
plexPinId = data.pin_id;
|
|
1265
|
+
|
|
1266
|
+
// Poll for completion
|
|
1267
|
+
pollPlexAuth();
|
|
1268
|
+
} catch (e) {
|
|
1269
|
+
alert('Error: ' + e.message);
|
|
1270
|
+
document.getElementById('step2-start').classList.remove('hidden');
|
|
1271
|
+
document.getElementById('step2-auth').classList.add('hidden');
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async function pollPlexAuth() {
|
|
1276
|
+
if (!plexPinId) return;
|
|
1277
|
+
|
|
1278
|
+
try {
|
|
1279
|
+
const resp = await fetch(`/api/plex/pin/${plexPinId}`);
|
|
1280
|
+
const data = await resp.json();
|
|
1281
|
+
|
|
1282
|
+
if (data.status === 'authenticated') {
|
|
1283
|
+
// Success - discover servers
|
|
1284
|
+
document.getElementById('step2-auth').classList.add('hidden');
|
|
1285
|
+
await discoverServers();
|
|
1286
|
+
} else if (data.status === 'pending') {
|
|
1287
|
+
setTimeout(pollPlexAuth, 2000);
|
|
1288
|
+
} else {
|
|
1289
|
+
alert(data.message || 'Authentication failed');
|
|
1290
|
+
document.getElementById('step2-start').classList.remove('hidden');
|
|
1291
|
+
document.getElementById('step2-auth').classList.add('hidden');
|
|
1292
|
+
}
|
|
1293
|
+
} catch (e) {
|
|
1294
|
+
setTimeout(pollPlexAuth, 3000);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
async function discoverServers() {
|
|
1299
|
+
try {
|
|
1300
|
+
const resp = await fetch('/api/plex/discover');
|
|
1301
|
+
const data = await resp.json();
|
|
1302
|
+
|
|
1303
|
+
if (data.status !== 'ok' || !data.servers || data.servers.length === 0) {
|
|
1304
|
+
// No servers found, go straight to done
|
|
1305
|
+
document.getElementById('step2-done').classList.remove('hidden');
|
|
1306
|
+
document.getElementById('plex-setup-result').textContent = 'Plex account linked (no servers found)';
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
discoveredServers = data.servers;
|
|
1311
|
+
renderDiscoveredServers();
|
|
1312
|
+
document.getElementById('step2-servers').classList.remove('hidden');
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
document.getElementById('step2-done').classList.remove('hidden');
|
|
1315
|
+
document.getElementById('plex-setup-result').textContent = 'Plex account linked';
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function renderDiscoveredServers() {
|
|
1320
|
+
const container = document.getElementById('discovered-servers');
|
|
1321
|
+
container.innerHTML = discoveredServers.map((server, i) => {
|
|
1322
|
+
const owned = server.owned ? '<span style="color: var(--success);">(owned)</span>' : '<span style="color: var(--text-dim);">(shared)</span>';
|
|
1323
|
+
const local = server.has_local ? '<span style="color: var(--accent);">local</span>' : '';
|
|
1324
|
+
return `<label class="toggle" style="margin-bottom: 0.5rem;">
|
|
1325
|
+
<input type="checkbox" data-server="${escapeHtml(server.name)}" checked>
|
|
1326
|
+
<span>${escapeHtml(server.name)} ${owned} ${local}</span>
|
|
1327
|
+
</label>`;
|
|
1328
|
+
}).join('');
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
async function addSelectedServers() {
|
|
1332
|
+
const selected = [];
|
|
1333
|
+
document.querySelectorAll('#discovered-servers input[type="checkbox"]:checked').forEach(el => {
|
|
1334
|
+
selected.push(el.dataset.server);
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
// Add each selected server
|
|
1338
|
+
for (const serverName of selected) {
|
|
1339
|
+
try {
|
|
1340
|
+
await fetch('/api/servers', {
|
|
1341
|
+
method: 'POST',
|
|
1342
|
+
headers: {'Content-Type': 'application/json'},
|
|
1343
|
+
body: JSON.stringify({name: serverName, server_name: serverName})
|
|
1344
|
+
});
|
|
1345
|
+
} catch (e) {
|
|
1346
|
+
console.error('Failed to add server:', serverName, e);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
document.getElementById('step2-servers').classList.add('hidden');
|
|
1351
|
+
document.getElementById('step2-done').classList.remove('hidden');
|
|
1352
|
+
document.getElementById('plex-setup-result').textContent =
|
|
1353
|
+
`Added ${selected.length} server(s)`;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function showManualPlexSetup() {
|
|
1357
|
+
document.getElementById('step2-start').classList.add('hidden');
|
|
1358
|
+
document.getElementById('step2-manual').classList.remove('hidden');
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function cancelManualPlexSetup() {
|
|
1362
|
+
document.getElementById('step2-manual').classList.add('hidden');
|
|
1363
|
+
document.getElementById('step2-start').classList.remove('hidden');
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function testAndSavePlex() {
|
|
1367
|
+
let url = document.getElementById('plex-url').value.trim();
|
|
1368
|
+
let token = document.getElementById('plex-token').value.trim();
|
|
1369
|
+
|
|
1370
|
+
// Default URL if empty
|
|
1371
|
+
if (!url) {
|
|
1372
|
+
url = 'http://localhost:32400';
|
|
1373
|
+
document.getElementById('plex-url').value = url;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Auto-strip common prefixes if user pasted the whole thing
|
|
1377
|
+
if (token.toLowerCase().startsWith('x-plex-token=')) {
|
|
1378
|
+
token = token.substring(13);
|
|
1379
|
+
} else if (token.startsWith('=')) {
|
|
1380
|
+
token = token.substring(1);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
if (!token) {
|
|
1384
|
+
alert('Please enter your Plex token');
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const resultBox = document.getElementById('plex-test-result');
|
|
1389
|
+
resultBox.classList.remove('hidden');
|
|
1390
|
+
resultBox.textContent = 'Testing connection...';
|
|
1391
|
+
|
|
1392
|
+
// Save first
|
|
1393
|
+
await fetch('/api/config', {
|
|
1394
|
+
method: 'POST',
|
|
1395
|
+
headers: {'Content-Type': 'application/json'},
|
|
1396
|
+
body: JSON.stringify({
|
|
1397
|
+
plex_url: url,
|
|
1398
|
+
plex_token: token
|
|
1399
|
+
})
|
|
1400
|
+
});
|
|
1401
|
+
|
|
1402
|
+
// Test
|
|
1403
|
+
const resp = await fetch('/api/plex/test', {method: 'POST'});
|
|
1404
|
+
const data = await resp.json();
|
|
1405
|
+
|
|
1406
|
+
if (data.status === 'ok') {
|
|
1407
|
+
document.getElementById('step2-manual').classList.add('hidden');
|
|
1408
|
+
document.getElementById('step2-done').classList.remove('hidden');
|
|
1409
|
+
document.getElementById('plex-setup-result').textContent =
|
|
1410
|
+
`Connected to ${data.server_name}`;
|
|
1411
|
+
} else {
|
|
1412
|
+
resultBox.textContent = 'Error: ' + data.message;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function finishSetup() {
|
|
1417
|
+
wizardInProgress = false;
|
|
1418
|
+
document.getElementById('wizard').classList.add('hidden');
|
|
1419
|
+
document.getElementById('dashboard').classList.remove('hidden');
|
|
1420
|
+
document.getElementById('main-nav').classList.remove('hidden');
|
|
1421
|
+
document.getElementById('settings-btn').classList.remove('hidden');
|
|
1422
|
+
showView('sync');
|
|
1423
|
+
loadStatus();
|
|
1424
|
+
loadConfig();
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function showReconfigure() {
|
|
1428
|
+
document.getElementById('reconfigure-modal').classList.remove('hidden');
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function closeModal() {
|
|
1432
|
+
document.getElementById('reconfigure-modal').classList.add('hidden');
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function confirmReconfigure() {
|
|
1436
|
+
closeModal();
|
|
1437
|
+
wizardInProgress = true;
|
|
1438
|
+
|
|
1439
|
+
document.getElementById('dashboard').classList.add('hidden');
|
|
1440
|
+
document.getElementById('wizard').classList.remove('hidden');
|
|
1441
|
+
document.getElementById('main-nav').classList.add('hidden');
|
|
1442
|
+
document.getElementById('settings-btn').classList.add('hidden');
|
|
1443
|
+
|
|
1444
|
+
// Reset wizard state
|
|
1445
|
+
document.getElementById('step1').classList.remove('hidden');
|
|
1446
|
+
document.getElementById('step2').classList.add('hidden');
|
|
1447
|
+
document.getElementById('step1-start').classList.remove('hidden');
|
|
1448
|
+
document.getElementById('step1-auth').classList.add('hidden');
|
|
1449
|
+
document.getElementById('step1-done').classList.add('hidden');
|
|
1450
|
+
document.getElementById('step2-config').classList.remove('hidden');
|
|
1451
|
+
document.getElementById('step2-done').classList.add('hidden');
|
|
1452
|
+
document.getElementById('step1-indicator').classList.add('active');
|
|
1453
|
+
document.getElementById('step1-indicator').classList.remove('done');
|
|
1454
|
+
document.getElementById('step2-indicator').classList.remove('active');
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Console management
|
|
1458
|
+
let consoleDetailsVisible = false;
|
|
1459
|
+
let consoleLogs = [];
|
|
1460
|
+
let consoleDetails = [];
|
|
1461
|
+
let lastServerLogIndex = 0;
|
|
1462
|
+
|
|
1463
|
+
function logToConsole(message, level = 'info') {
|
|
1464
|
+
const time = new Date().toLocaleTimeString();
|
|
1465
|
+
consoleLogs.push({ time, message, level });
|
|
1466
|
+
updateConsoleDisplay();
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function addConsoleDetail(message) {
|
|
1470
|
+
consoleDetails.push(message);
|
|
1471
|
+
updateConsoleDisplay();
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function clearConsole() {
|
|
1475
|
+
consoleLogs = [];
|
|
1476
|
+
consoleDetails = [];
|
|
1477
|
+
lastServerLogIndex = 0;
|
|
1478
|
+
updateConsoleDisplay();
|
|
1479
|
+
hideProgress();
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function showProgress(phase, percent, activity) {
|
|
1483
|
+
document.getElementById('progress-container').classList.remove('hidden');
|
|
1484
|
+
const fillEl = document.getElementById('progress-fill');
|
|
1485
|
+
fillEl.style.width = percent + '%';
|
|
1486
|
+
document.getElementById('progress-phase').textContent = 'Phase ' + phase + '/4';
|
|
1487
|
+
const activityEl = document.getElementById('progress-activity');
|
|
1488
|
+
|
|
1489
|
+
// Parse task progress from labels like "Movies 500/9068" or "Episodes 1000/51505"
|
|
1490
|
+
const taskMatch = activity ? activity.match(/^(\w+)\s+(\d+)\/(\d+)$/) : null;
|
|
1491
|
+
const taskContainer = document.getElementById('task-progress-container');
|
|
1492
|
+
|
|
1493
|
+
if (taskMatch) {
|
|
1494
|
+
const taskName = taskMatch[1];
|
|
1495
|
+
const current = parseInt(taskMatch[2]);
|
|
1496
|
+
const total = parseInt(taskMatch[3]);
|
|
1497
|
+
const taskPercent = total > 0 ? (current / total) * 100 : 0;
|
|
1498
|
+
|
|
1499
|
+
// Show task progress bar
|
|
1500
|
+
taskContainer.classList.remove('hidden');
|
|
1501
|
+
document.getElementById('task-activity').textContent = `${taskName} ${current.toLocaleString()} / ${total.toLocaleString()}`;
|
|
1502
|
+
document.getElementById('task-percent').textContent = Math.round(taskPercent) + '%';
|
|
1503
|
+
document.getElementById('task-progress-fill').style.width = taskPercent + '%';
|
|
1504
|
+
|
|
1505
|
+
// Main activity shows phase description
|
|
1506
|
+
activityEl.textContent = `Processing ${taskName.toLowerCase()}`;
|
|
1507
|
+
} else {
|
|
1508
|
+
// Hide task progress bar when not processing items
|
|
1509
|
+
taskContainer.classList.add('hidden');
|
|
1510
|
+
activityEl.textContent = activity || '';
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Add pulsing animation during fetch operations
|
|
1514
|
+
const isFetching = activity && (activity.includes('Fetching') || activity.includes('Trakt') || activity.includes('Plex')) && !activity.includes('complete');
|
|
1515
|
+
if (isFetching) {
|
|
1516
|
+
activityEl.classList.add('fetching');
|
|
1517
|
+
fillEl.classList.add('fetching');
|
|
1518
|
+
} else {
|
|
1519
|
+
activityEl.classList.remove('fetching');
|
|
1520
|
+
fillEl.classList.remove('fetching');
|
|
1521
|
+
}
|
|
1522
|
+
document.getElementById('progress-percent').textContent = Math.round(percent) + '%';
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function hideProgress() {
|
|
1526
|
+
document.getElementById('progress-container').classList.add('hidden');
|
|
1527
|
+
document.getElementById('progress-fill').style.width = '0%';
|
|
1528
|
+
document.getElementById('task-progress-container').classList.add('hidden');
|
|
1529
|
+
document.getElementById('task-progress-fill').style.width = '0%';
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function updateConsoleDisplay() {
|
|
1533
|
+
const output = document.getElementById('console-output');
|
|
1534
|
+
const details = document.getElementById('console-details');
|
|
1535
|
+
|
|
1536
|
+
if (consoleLogs.length === 0) {
|
|
1537
|
+
output.textContent = 'Waiting for sync...';
|
|
1538
|
+
} else {
|
|
1539
|
+
output.innerHTML = consoleLogs.map(log => {
|
|
1540
|
+
return `<span class="log-dim">[${log.time}]</span> <span class="log-${log.level}">${escapeHtml(log.message)}</span>`;
|
|
1541
|
+
}).join('\n');
|
|
1542
|
+
output.scrollTop = output.scrollHeight;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (consoleDetails.length > 0) {
|
|
1546
|
+
details.innerHTML = consoleDetails.map(d => escapeHtml(d)).join('\n');
|
|
1547
|
+
details.scrollTop = details.scrollHeight;
|
|
1548
|
+
} else {
|
|
1549
|
+
details.textContent = 'No details yet.';
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function toggleConsoleDetails() {
|
|
1554
|
+
const details = document.getElementById('console-details');
|
|
1555
|
+
const toggleText = document.getElementById('details-toggle-text');
|
|
1556
|
+
consoleDetailsVisible = !consoleDetailsVisible;
|
|
1557
|
+
|
|
1558
|
+
if (consoleDetailsVisible) {
|
|
1559
|
+
details.classList.remove('hidden');
|
|
1560
|
+
toggleText.textContent = 'Hide Details';
|
|
1561
|
+
} else {
|
|
1562
|
+
details.classList.add('hidden');
|
|
1563
|
+
toggleText.textContent = 'Show Details';
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function escapeHtml(text) {
|
|
1568
|
+
const div = document.createElement('div');
|
|
1569
|
+
div.textContent = text;
|
|
1570
|
+
return div.innerHTML;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Dashboard functions
|
|
1574
|
+
async function startSync(dryRun) {
|
|
1575
|
+
const btn = document.getElementById('sync-btn');
|
|
1576
|
+
const dryBtn = document.getElementById('dry-run-btn');
|
|
1577
|
+
const cancelBtn = document.getElementById('cancel-btn');
|
|
1578
|
+
const verbose = document.getElementById('verbose-logging').checked;
|
|
1579
|
+
|
|
1580
|
+
btn.disabled = true;
|
|
1581
|
+
btn.textContent = 'Syncing...';
|
|
1582
|
+
dryBtn.classList.add('hidden');
|
|
1583
|
+
cancelBtn.classList.remove('hidden');
|
|
1584
|
+
document.getElementById('verbose-logging').disabled = true;
|
|
1585
|
+
|
|
1586
|
+
clearConsole();
|
|
1587
|
+
logToConsole(dryRun ? 'Starting dry run...' : 'Starting sync...', 'info');
|
|
1588
|
+
|
|
1589
|
+
try {
|
|
1590
|
+
const resp = await fetch('/api/sync', {
|
|
1591
|
+
method: 'POST',
|
|
1592
|
+
headers: {'Content-Type': 'application/json'},
|
|
1593
|
+
body: JSON.stringify({dry_run: dryRun, verbose: verbose})
|
|
1594
|
+
});
|
|
1595
|
+
const data = await resp.json();
|
|
1596
|
+
|
|
1597
|
+
if (data.status === 'started') {
|
|
1598
|
+
syncPolling = true;
|
|
1599
|
+
pollSyncStatus();
|
|
1600
|
+
} else {
|
|
1601
|
+
logToConsole(data.message || 'Error starting sync', 'error');
|
|
1602
|
+
resetSyncButtons();
|
|
1603
|
+
}
|
|
1604
|
+
} catch (e) {
|
|
1605
|
+
logToConsole('Error: ' + e.message, 'error');
|
|
1606
|
+
resetSyncButtons();
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function resetSyncButtons() {
|
|
1611
|
+
document.getElementById('sync-btn').disabled = false;
|
|
1612
|
+
document.getElementById('sync-btn').textContent = 'Start Sync';
|
|
1613
|
+
document.getElementById('dry-run-btn').classList.remove('hidden');
|
|
1614
|
+
document.getElementById('verbose-logging').disabled = false;
|
|
1615
|
+
const cancelBtn = document.getElementById('cancel-btn');
|
|
1616
|
+
cancelBtn.classList.add('hidden');
|
|
1617
|
+
cancelBtn.disabled = false;
|
|
1618
|
+
cancelBtn.textContent = 'Cancel';
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
async function cancelSync() {
|
|
1622
|
+
const cancelBtn = document.getElementById('cancel-btn');
|
|
1623
|
+
cancelBtn.disabled = true;
|
|
1624
|
+
cancelBtn.textContent = 'Cancelling...';
|
|
1625
|
+
try {
|
|
1626
|
+
await fetch('/api/sync/cancel', { method: 'POST' });
|
|
1627
|
+
logToConsole('Cancel requested...', 'warning');
|
|
1628
|
+
} catch (e) {
|
|
1629
|
+
logToConsole('Failed to cancel: ' + e.message, 'error');
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
async function pollSyncStatus() {
|
|
1634
|
+
const poll = async () => {
|
|
1635
|
+
try {
|
|
1636
|
+
const resp = await fetch('/api/sync/status?_=' + Date.now());
|
|
1637
|
+
const data = await resp.json();
|
|
1638
|
+
console.log('Poll:', data.logs?.length, 'logs, running:', data.running);
|
|
1639
|
+
|
|
1640
|
+
// Update logs from server
|
|
1641
|
+
if (data.logs && data.logs.length > lastServerLogIndex) {
|
|
1642
|
+
const newLogs = data.logs.slice(lastServerLogIndex);
|
|
1643
|
+
lastServerLogIndex = data.logs.length;
|
|
1644
|
+
newLogs.forEach(log => {
|
|
1645
|
+
if (log.startsWith('PROGRESS:')) {
|
|
1646
|
+
// Format: PROGRESS:phase:percent:label
|
|
1647
|
+
const parts = log.substring(9).split(':');
|
|
1648
|
+
showProgress(parseInt(parts[0]), parseFloat(parts[1]), parts[2] || null);
|
|
1649
|
+
} else if (log.startsWith('DETAIL:')) {
|
|
1650
|
+
addConsoleDetail(log.substring(7));
|
|
1651
|
+
} else if (log.startsWith('ERROR:')) {
|
|
1652
|
+
logToConsole(log.substring(6), 'error');
|
|
1653
|
+
} else if (log.startsWith('SUCCESS:')) {
|
|
1654
|
+
logToConsole(log.substring(8), 'success');
|
|
1655
|
+
} else if (log.startsWith('WARNING:')) {
|
|
1656
|
+
logToConsole(log.substring(8), 'warning');
|
|
1657
|
+
} else {
|
|
1658
|
+
logToConsole(log, 'info');
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
if (data.running) {
|
|
1664
|
+
setTimeout(poll, 250);
|
|
1665
|
+
} else {
|
|
1666
|
+
syncPolling = false;
|
|
1667
|
+
hideProgress();
|
|
1668
|
+
resetSyncButtons();
|
|
1669
|
+
loadStatus();
|
|
1670
|
+
|
|
1671
|
+
if (data.last_result) {
|
|
1672
|
+
if (data.last_result.cancelled) {
|
|
1673
|
+
// Already logged via WARNING message
|
|
1674
|
+
} else if (data.last_result.error) {
|
|
1675
|
+
logToConsole(data.last_result.error, 'error');
|
|
1676
|
+
} else {
|
|
1677
|
+
logToConsole('Sync complete!', 'success');
|
|
1678
|
+
addConsoleDetail(`Added to Trakt: ${data.last_result.added_to_trakt}`);
|
|
1679
|
+
addConsoleDetail(`Added to Plex: ${data.last_result.added_to_plex}`);
|
|
1680
|
+
addConsoleDetail(`Ratings synced: ${data.last_result.ratings_synced}`);
|
|
1681
|
+
addConsoleDetail(`Duration: ${data.last_result.duration?.toFixed(1)}s`);
|
|
1682
|
+
if (data.last_result.errors && data.last_result.errors.length > 0) {
|
|
1683
|
+
logToConsole(`${data.last_result.errors.length} errors occurred`, 'warning');
|
|
1684
|
+
data.last_result.errors.forEach(e => addConsoleDetail(`Error: ${e}`));
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
} catch (e) {
|
|
1690
|
+
console.error('Poll error:', e);
|
|
1691
|
+
setTimeout(poll, 1000);
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
|
|
1695
|
+
poll();
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
async function saveSyncOptions() {
|
|
1699
|
+
const status = document.getElementById('sync-options-status');
|
|
1700
|
+
status.textContent = 'Saving...';
|
|
1701
|
+
status.style.color = 'var(--text-dim)';
|
|
1702
|
+
|
|
1703
|
+
await fetch('/api/config', {
|
|
1704
|
+
method: 'POST',
|
|
1705
|
+
headers: {'Content-Type': 'application/json'},
|
|
1706
|
+
body: JSON.stringify({
|
|
1707
|
+
watched_plex_to_trakt: document.getElementById('watched-plex-to-trakt').checked,
|
|
1708
|
+
watched_trakt_to_plex: document.getElementById('watched-trakt-to-plex').checked,
|
|
1709
|
+
ratings_plex_to_trakt: document.getElementById('ratings-plex-to-trakt').checked,
|
|
1710
|
+
ratings_trakt_to_plex: document.getElementById('ratings-trakt-to-plex').checked,
|
|
1711
|
+
collection_plex_to_trakt: document.getElementById('collection-plex-to-trakt').checked,
|
|
1712
|
+
watchlist_plex_to_trakt: document.getElementById('watchlist-plex-to-trakt').checked,
|
|
1713
|
+
watchlist_trakt_to_plex: document.getElementById('watchlist-trakt-to-plex').checked
|
|
1714
|
+
})
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
status.textContent = 'Saved';
|
|
1718
|
+
status.style.color = 'var(--success)';
|
|
1719
|
+
setTimeout(() => { status.textContent = ''; }, 2000);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
async function saveSchedulerOptions() {
|
|
1723
|
+
const status = document.getElementById('scheduler-status');
|
|
1724
|
+
status.textContent = 'Saving...';
|
|
1725
|
+
status.style.color = 'var(--text-dim)';
|
|
1726
|
+
|
|
1727
|
+
await fetch('/api/config', {
|
|
1728
|
+
method: 'POST',
|
|
1729
|
+
headers: {'Content-Type': 'application/json'},
|
|
1730
|
+
body: JSON.stringify({
|
|
1731
|
+
scheduler_enabled: document.getElementById('scheduler-enabled').checked,
|
|
1732
|
+
scheduler_interval_hours: parseInt(document.getElementById('scheduler-interval').value) || 6
|
|
1733
|
+
})
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
status.textContent = 'Saved';
|
|
1737
|
+
status.style.color = 'var(--success)';
|
|
1738
|
+
setTimeout(() => { status.textContent = ''; }, 2000);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
async function loadTraktAccount() {
|
|
1742
|
+
try {
|
|
1743
|
+
const resp = await fetch('/api/trakt/account');
|
|
1744
|
+
const data = await resp.json();
|
|
1745
|
+
if (data.status === 'ok') {
|
|
1746
|
+
document.getElementById('trakt-vip-status').textContent = data.is_vip ? 'VIP' : 'Free';
|
|
1747
|
+
document.getElementById('trakt-vip-status').className = 'value ' + (data.is_vip ? 'success' : '');
|
|
1748
|
+
document.getElementById('trakt-collection-limit').textContent = data.is_vip ? 'Unlimited' : data.limits.collection;
|
|
1749
|
+
document.getElementById('trakt-watchlist-limit').textContent = data.is_vip ? 'Unlimited' : data.limits.watchlist;
|
|
1750
|
+
}
|
|
1751
|
+
} catch (e) {
|
|
1752
|
+
console.error('Failed to load Trakt account:', e);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
async function loadPlexServer() {
|
|
1757
|
+
const container = document.getElementById('plex-servers-stats');
|
|
1758
|
+
try {
|
|
1759
|
+
const serversResp = await fetch('/api/servers');
|
|
1760
|
+
const serversData = await serversResp.json();
|
|
1761
|
+
|
|
1762
|
+
if (serversData.status !== 'ok' || !serversData.servers.length) {
|
|
1763
|
+
container.innerHTML = '<span style="color: var(--text-dim);">No servers configured</span>';
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
let html = '';
|
|
1768
|
+
for (const server of serversData.servers) {
|
|
1769
|
+
const statusClass = server.enabled ? 'success' : 'warning';
|
|
1770
|
+
const statusText = server.enabled ? 'Enabled' : 'Disabled';
|
|
1771
|
+
|
|
1772
|
+
// Try to get library counts
|
|
1773
|
+
let movieCount = '-';
|
|
1774
|
+
let showCount = '-';
|
|
1775
|
+
try {
|
|
1776
|
+
const libResp = await fetch(`/api/servers/${encodeURIComponent(server.name)}/libraries`);
|
|
1777
|
+
const libData = await libResp.json();
|
|
1778
|
+
if (libData.status === 'ok') {
|
|
1779
|
+
const movieTotal = libData.available.movie.length;
|
|
1780
|
+
const showTotal = libData.available.show.length;
|
|
1781
|
+
// Empty selected = all enabled
|
|
1782
|
+
const movieEnabled = libData.selected.movie.length === 0 ? movieTotal : libData.selected.movie.length;
|
|
1783
|
+
const showEnabled = libData.selected.show.length === 0 ? showTotal : libData.selected.show.length;
|
|
1784
|
+
|
|
1785
|
+
movieCount = movieEnabled === movieTotal
|
|
1786
|
+
? `${movieTotal} libraries`
|
|
1787
|
+
: `${movieTotal} libraries (${movieEnabled} enabled)`;
|
|
1788
|
+
showCount = showEnabled === showTotal
|
|
1789
|
+
? `${showTotal} libraries`
|
|
1790
|
+
: `${showTotal} libraries (${showEnabled} enabled)`;
|
|
1791
|
+
}
|
|
1792
|
+
} catch (e) {
|
|
1793
|
+
console.error(`Failed to load libraries for ${server.name}:`, e);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
html += `<div style="margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border);">
|
|
1797
|
+
<div class="row">
|
|
1798
|
+
<span class="label">Server</span>
|
|
1799
|
+
<span class="value">${escapeHtml(server.name)}</span>
|
|
1800
|
+
</div>
|
|
1801
|
+
<div class="row">
|
|
1802
|
+
<span class="label">Status</span>
|
|
1803
|
+
<span class="value ${statusClass}">${statusText}</span>
|
|
1804
|
+
</div>
|
|
1805
|
+
<div class="row">
|
|
1806
|
+
<span class="label">Movies</span>
|
|
1807
|
+
<span class="value">${movieCount}</span>
|
|
1808
|
+
</div>
|
|
1809
|
+
<div class="row">
|
|
1810
|
+
<span class="label">Shows</span>
|
|
1811
|
+
<span class="value">${showCount}</span>
|
|
1812
|
+
</div>
|
|
1813
|
+
</div>`;
|
|
1814
|
+
}
|
|
1815
|
+
// Remove last border
|
|
1816
|
+
container.innerHTML = html.replace(/border-bottom: 1px solid var\(--border\);">([^<]*<\/div>\s*)$/, '">$1');
|
|
1817
|
+
} catch (e) {
|
|
1818
|
+
container.innerHTML = '<span style="color: var(--error);">Error loading servers</span>';
|
|
1819
|
+
console.error('Failed to load Plex servers:', e);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
// Server management
|
|
1824
|
+
let settingsPinId = null;
|
|
1825
|
+
|
|
1826
|
+
async function refreshServersInSettings() {
|
|
1827
|
+
const container = document.getElementById('servers-list');
|
|
1828
|
+
container.innerHTML = '<span style="color: var(--text-dim);">Loading...</span>';
|
|
1829
|
+
|
|
1830
|
+
try {
|
|
1831
|
+
const resp = await fetch('/api/servers');
|
|
1832
|
+
const data = await resp.json();
|
|
1833
|
+
|
|
1834
|
+
if (!data.servers || data.servers.length === 0) {
|
|
1835
|
+
container.innerHTML = '<span style="color: var(--text-dim);">No servers configured.</span>';
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
container.innerHTML = data.servers.map(server => {
|
|
1840
|
+
const status = server.enabled
|
|
1841
|
+
? '<span style="color: var(--success);">Enabled</span>'
|
|
1842
|
+
: '<span style="color: var(--text-dim);">Disabled</span>';
|
|
1843
|
+
return `<div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid var(--border);">
|
|
1844
|
+
<div>
|
|
1845
|
+
<strong>${escapeHtml(server.name)}</strong>
|
|
1846
|
+
<span style="margin-left: 0.5rem; font-size: 0.8rem;">${status}</span>
|
|
1847
|
+
</div>
|
|
1848
|
+
<div style="display: flex; gap: 0.5rem;">
|
|
1849
|
+
<button class="btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.8rem;"
|
|
1850
|
+
onclick="toggleServer('${escapeHtml(server.name)}', ${!server.enabled})">
|
|
1851
|
+
${server.enabled ? 'Disable' : 'Enable'}
|
|
1852
|
+
</button>
|
|
1853
|
+
<button class="btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.8rem; color: var(--error);"
|
|
1854
|
+
onclick="removeServer('${escapeHtml(server.name)}')">
|
|
1855
|
+
Remove
|
|
1856
|
+
</button>
|
|
1857
|
+
</div>
|
|
1858
|
+
</div>`;
|
|
1859
|
+
}).join('');
|
|
1860
|
+
} catch (e) {
|
|
1861
|
+
container.innerHTML = '<span style="color: var(--error);">Error loading servers</span>';
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
async function toggleServer(name, enabled) {
|
|
1866
|
+
await fetch(`/api/servers/${encodeURIComponent(name)}`, {
|
|
1867
|
+
method: 'PUT',
|
|
1868
|
+
headers: {'Content-Type': 'application/json'},
|
|
1869
|
+
body: JSON.stringify({enabled})
|
|
1870
|
+
});
|
|
1871
|
+
refreshServersInSettings();
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
async function removeServer(name) {
|
|
1875
|
+
if (!confirm(`Remove server "${name}"?`)) return;
|
|
1876
|
+
await fetch(`/api/servers/${encodeURIComponent(name)}`, {method: 'DELETE'});
|
|
1877
|
+
refreshServersInSettings();
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
async function startPlexReauth() {
|
|
1881
|
+
document.getElementById('servers-reauth').classList.remove('hidden');
|
|
1882
|
+
|
|
1883
|
+
try {
|
|
1884
|
+
const resp = await fetch('/api/plex/pin', {method: 'POST'});
|
|
1885
|
+
const data = await resp.json();
|
|
1886
|
+
|
|
1887
|
+
if (data.status !== 'ok') {
|
|
1888
|
+
alert(data.message || 'Failed to start PIN login');
|
|
1889
|
+
document.getElementById('servers-reauth').classList.add('hidden');
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
document.getElementById('reauth-pin-code').textContent = data.pin;
|
|
1894
|
+
settingsPinId = data.pin_id;
|
|
1895
|
+
pollSettingsPlexAuth();
|
|
1896
|
+
} catch (e) {
|
|
1897
|
+
alert('Error: ' + e.message);
|
|
1898
|
+
document.getElementById('servers-reauth').classList.add('hidden');
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
async function pollSettingsPlexAuth() {
|
|
1903
|
+
if (!settingsPinId) return;
|
|
1904
|
+
|
|
1905
|
+
try {
|
|
1906
|
+
const resp = await fetch(`/api/plex/pin/${settingsPinId}`);
|
|
1907
|
+
const data = await resp.json();
|
|
1908
|
+
|
|
1909
|
+
if (data.status === 'authenticated') {
|
|
1910
|
+
document.getElementById('servers-reauth').classList.add('hidden');
|
|
1911
|
+
settingsPinId = null;
|
|
1912
|
+
// Discover and add servers
|
|
1913
|
+
await discoverAndAddServers();
|
|
1914
|
+
} else if (data.status === 'pending') {
|
|
1915
|
+
setTimeout(pollSettingsPlexAuth, 2000);
|
|
1916
|
+
} else {
|
|
1917
|
+
alert(data.message || 'Authentication failed');
|
|
1918
|
+
document.getElementById('servers-reauth').classList.add('hidden');
|
|
1919
|
+
}
|
|
1920
|
+
} catch (e) {
|
|
1921
|
+
setTimeout(pollSettingsPlexAuth, 3000);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
async function discoverAndAddServers() {
|
|
1926
|
+
try {
|
|
1927
|
+
const resp = await fetch('/api/plex/discover');
|
|
1928
|
+
const data = await resp.json();
|
|
1929
|
+
|
|
1930
|
+
if (data.status === 'ok' && data.servers) {
|
|
1931
|
+
// Add any new servers
|
|
1932
|
+
for (const server of data.servers) {
|
|
1933
|
+
if (!server.configured) {
|
|
1934
|
+
await fetch('/api/servers', {
|
|
1935
|
+
method: 'POST',
|
|
1936
|
+
headers: {'Content-Type': 'application/json'},
|
|
1937
|
+
body: JSON.stringify({name: server.name, server_name: server.name})
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
refreshServersInSettings();
|
|
1943
|
+
} catch (e) {
|
|
1944
|
+
refreshServersInSettings();
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// Library and sync options (per-server)
|
|
1949
|
+
let serverData = {}; // {serverName: {libraries, sync, enabled}}
|
|
1950
|
+
let globalSync = {};
|
|
1951
|
+
|
|
1952
|
+
async function loadLibraries() {
|
|
1953
|
+
const loading = document.getElementById('libraries-loading');
|
|
1954
|
+
const container = document.getElementById('libraries-container');
|
|
1955
|
+
const error = document.getElementById('libraries-error');
|
|
1956
|
+
|
|
1957
|
+
loading.classList.remove('hidden');
|
|
1958
|
+
container.classList.add('hidden');
|
|
1959
|
+
error.classList.add('hidden');
|
|
1960
|
+
|
|
1961
|
+
try {
|
|
1962
|
+
// Get list of servers with sync options
|
|
1963
|
+
const serversResp = await fetch('/api/servers');
|
|
1964
|
+
const serversResult = await serversResp.json();
|
|
1965
|
+
|
|
1966
|
+
if (serversResult.status !== 'ok' || !serversResult.servers.length) {
|
|
1967
|
+
loading.classList.add('hidden');
|
|
1968
|
+
error.classList.remove('hidden');
|
|
1969
|
+
error.textContent = 'No servers configured';
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
globalSync = serversResult.global_sync || {};
|
|
1974
|
+
|
|
1975
|
+
// Load libraries for each server
|
|
1976
|
+
serverData = {};
|
|
1977
|
+
for (const server of serversResult.servers) {
|
|
1978
|
+
try {
|
|
1979
|
+
const resp = await fetch(`/api/servers/${encodeURIComponent(server.name)}/libraries`);
|
|
1980
|
+
const data = await resp.json();
|
|
1981
|
+
if (data.status === 'ok') {
|
|
1982
|
+
serverData[server.name] = {
|
|
1983
|
+
available: data.available,
|
|
1984
|
+
selected: data.selected,
|
|
1985
|
+
enabled: server.enabled,
|
|
1986
|
+
sync: server.sync || {}
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
} catch (e) {
|
|
1990
|
+
console.error(`Failed to load libraries for ${server.name}:`, e);
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
renderServerSettings();
|
|
1995
|
+
loading.classList.add('hidden');
|
|
1996
|
+
container.classList.remove('hidden');
|
|
1997
|
+
} catch (e) {
|
|
1998
|
+
loading.classList.add('hidden');
|
|
1999
|
+
error.classList.remove('hidden');
|
|
2000
|
+
error.textContent = 'Error: ' + e.message;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
function renderServerSettings() {
|
|
2005
|
+
const container = document.getElementById('libraries-container');
|
|
2006
|
+
const serverNames = Object.keys(serverData);
|
|
2007
|
+
|
|
2008
|
+
if (serverNames.length === 0) {
|
|
2009
|
+
container.innerHTML = '<span style="color: var(--text-dim);">No servers available</span>';
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
let html = '';
|
|
2014
|
+
for (const serverName of serverNames) {
|
|
2015
|
+
const data = serverData[serverName];
|
|
2016
|
+
const statusClass = data.enabled ? 'success' : 'warning';
|
|
2017
|
+
const statusText = data.enabled ? '' : ' (disabled)';
|
|
2018
|
+
const safeServer = escapeHtml(serverName);
|
|
2019
|
+
|
|
2020
|
+
html += `<div style="margin-bottom: 1.5rem; padding: 1rem; background: var(--bg-secondary); border-radius: 8px;">
|
|
2021
|
+
<h3 style="font-size: 1rem; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
|
2022
|
+
<span>${safeServer}</span>
|
|
2023
|
+
<span class="value ${statusClass}" style="font-size: 0.75rem;">${statusText}</span>
|
|
2024
|
+
</h3>
|
|
2025
|
+
|
|
2026
|
+
<div style="margin-bottom: 1rem;">
|
|
2027
|
+
<h4 style="font-size: 0.875rem; color: var(--text-dim); margin-bottom: 0.5rem;">Libraries</h4>
|
|
2028
|
+
<div class="grid" style="gap: 1rem;">
|
|
2029
|
+
<div>
|
|
2030
|
+
<h5 style="font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.25rem;">Movies</h5>
|
|
2031
|
+
${renderLibTypeCheckboxes(serverName, 'movie', data.available.movie, data.selected.movie)}
|
|
2032
|
+
</div>
|
|
2033
|
+
<div>
|
|
2034
|
+
<h5 style="font-size: 0.8rem; color: var(--text-dim); margin-bottom: 0.25rem;">Shows</h5>
|
|
2035
|
+
${renderLibTypeCheckboxes(serverName, 'show', data.available.show, data.selected.show)}
|
|
2036
|
+
</div>
|
|
2037
|
+
</div>
|
|
2038
|
+
</div>
|
|
2039
|
+
|
|
2040
|
+
<div style="border-top: 1px solid var(--border); padding-top: 1rem;">
|
|
2041
|
+
<h4 style="font-size: 0.875rem; color: var(--text-dim); margin-bottom: 0.5rem;">Sync Options <span style="font-size: 0.75rem;">(override global)</span></h4>
|
|
2042
|
+
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
|
|
2043
|
+
<div>
|
|
2044
|
+
<div style="font-size: 0.75rem; color: var(--text-dim); margin-bottom: 0.25rem;">Plex → Trakt</div>
|
|
2045
|
+
${renderSyncOption(serverName, 'watched_plex_to_trakt', 'Watched', data.sync)}
|
|
2046
|
+
${renderSyncOption(serverName, 'ratings_plex_to_trakt', 'Ratings', data.sync)}
|
|
2047
|
+
${renderSyncOption(serverName, 'collection_plex_to_trakt', 'Collection', data.sync)}
|
|
2048
|
+
${renderSyncOption(serverName, 'watchlist_plex_to_trakt', 'Watchlist', data.sync)}
|
|
2049
|
+
</div>
|
|
2050
|
+
<div>
|
|
2051
|
+
<div style="font-size: 0.75rem; color: var(--text-dim); margin-bottom: 0.25rem;">Trakt → Plex</div>
|
|
2052
|
+
${renderSyncOption(serverName, 'watched_trakt_to_plex', 'Watched', data.sync)}
|
|
2053
|
+
${renderSyncOption(serverName, 'ratings_trakt_to_plex', 'Ratings', data.sync)}
|
|
2054
|
+
${renderSyncOption(serverName, 'watchlist_trakt_to_plex', 'Watchlist', data.sync)}
|
|
2055
|
+
</div>
|
|
2056
|
+
</div>
|
|
2057
|
+
</div>
|
|
2058
|
+
|
|
2059
|
+
<div id="server-status-${safeServer}" class="mt-2" style="font-size: 0.8rem; color: var(--text-dim);"></div>
|
|
2060
|
+
</div>`;
|
|
2061
|
+
}
|
|
2062
|
+
container.innerHTML = html;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function renderLibTypeCheckboxes(serverName, libType, available, selected) {
|
|
2066
|
+
if (!available || available.length === 0) {
|
|
2067
|
+
return `<span style="color: var(--text-dim);">None found</span>`;
|
|
2068
|
+
}
|
|
2069
|
+
return available.map(lib => {
|
|
2070
|
+
const checked = selected.length === 0 || selected.includes(lib);
|
|
2071
|
+
const safeServer = escapeHtml(serverName);
|
|
2072
|
+
const safeLib = escapeHtml(lib);
|
|
2073
|
+
return `<label class="toggle" style="margin-bottom: 0.5rem;">
|
|
2074
|
+
<input type="checkbox" data-server="${safeServer}" data-type="${libType}" data-lib="${safeLib}" ${checked ? 'checked' : ''} onchange="saveServerSettings('${safeServer}')">
|
|
2075
|
+
<span>${safeLib}</span>
|
|
2076
|
+
</label>`;
|
|
2077
|
+
}).join('');
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function renderSyncOption(serverName, option, label, serverSync) {
|
|
2081
|
+
const safeServer = escapeHtml(serverName);
|
|
2082
|
+
const globalVal = globalSync[option] ?? false;
|
|
2083
|
+
const serverVal = serverSync[option]; // null = use global
|
|
2084
|
+
const globalLabel = globalVal ? 'On' : 'Off';
|
|
2085
|
+
|
|
2086
|
+
// tri-state: null (global), true, false
|
|
2087
|
+
let selectHtml = `<select data-server="${safeServer}" data-sync="${option}" onchange="saveServerSettings('${safeServer}')" style="padding: 0.25rem 0.5rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font-size: 0.8rem;">`;
|
|
2088
|
+
selectHtml += `<option value="null" ${serverVal === null || serverVal === undefined ? 'selected' : ''}>Global (${globalLabel})</option>`;
|
|
2089
|
+
selectHtml += `<option value="true" ${serverVal === true ? 'selected' : ''}>On</option>`;
|
|
2090
|
+
selectHtml += `<option value="false" ${serverVal === false ? 'selected' : ''}>Off</option>`;
|
|
2091
|
+
selectHtml += `</select>`;
|
|
2092
|
+
|
|
2093
|
+
return `<div style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
|
|
2094
|
+
<span style="font-size: 0.85rem;">${label}</span>
|
|
2095
|
+
${selectHtml}
|
|
2096
|
+
</div>`;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
async function saveServerSettings(serverName) {
|
|
2100
|
+
const statusEl = document.getElementById(`server-status-${serverName}`);
|
|
2101
|
+
if (statusEl) {
|
|
2102
|
+
statusEl.textContent = 'Saving...';
|
|
2103
|
+
statusEl.style.color = 'var(--text-dim)';
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
const data = serverData[serverName];
|
|
2107
|
+
|
|
2108
|
+
// Collect library selections
|
|
2109
|
+
const movieLibs = [];
|
|
2110
|
+
const showLibs = [];
|
|
2111
|
+
document.querySelectorAll(`input[data-server="${serverName}"][data-type="movie"]:checked`).forEach(el => {
|
|
2112
|
+
movieLibs.push(el.dataset.lib);
|
|
2113
|
+
});
|
|
2114
|
+
document.querySelectorAll(`input[data-server="${serverName}"][data-type="show"]:checked`).forEach(el => {
|
|
2115
|
+
showLibs.push(el.dataset.lib);
|
|
2116
|
+
});
|
|
2117
|
+
const allMovies = movieLibs.length === data.available.movie.length;
|
|
2118
|
+
const allShows = showLibs.length === data.available.show.length;
|
|
2119
|
+
|
|
2120
|
+
// Collect sync overrides
|
|
2121
|
+
const syncOverrides = {};
|
|
2122
|
+
document.querySelectorAll(`select[data-server="${serverName}"][data-sync]`).forEach(el => {
|
|
2123
|
+
const opt = el.dataset.sync;
|
|
2124
|
+
const val = el.value;
|
|
2125
|
+
syncOverrides[opt] = val === 'null' ? null : val === 'true';
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
await fetch(`/api/servers/${encodeURIComponent(serverName)}`, {
|
|
2129
|
+
method: 'PUT',
|
|
2130
|
+
headers: {'Content-Type': 'application/json'},
|
|
2131
|
+
body: JSON.stringify({
|
|
2132
|
+
movie_libraries: allMovies ? [] : movieLibs,
|
|
2133
|
+
show_libraries: allShows ? [] : showLibs,
|
|
2134
|
+
sync: syncOverrides
|
|
2135
|
+
})
|
|
2136
|
+
});
|
|
2137
|
+
|
|
2138
|
+
// Update local state
|
|
2139
|
+
data.selected.movie = allMovies ? [] : movieLibs;
|
|
2140
|
+
data.selected.show = allShows ? [] : showLibs;
|
|
2141
|
+
data.sync = syncOverrides;
|
|
2142
|
+
|
|
2143
|
+
if (statusEl) {
|
|
2144
|
+
statusEl.textContent = 'Saved';
|
|
2145
|
+
statusEl.style.color = 'var(--success)';
|
|
2146
|
+
setTimeout(() => { statusEl.textContent = ''; }, 2000);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
async function shutdownServer() {
|
|
2151
|
+
if (!confirm('Shutdown the Pakt web server?')) {
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
try {
|
|
2156
|
+
await fetch('/api/shutdown', {method: 'POST'});
|
|
2157
|
+
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;flex-direction:column;gap:1rem;"><h1 style="color:#2dd4bf;">Pakt</h1><p style="color:#888;">Server stopped. You can close this tab.</p></div>';
|
|
2158
|
+
} catch (e) {
|
|
2159
|
+
// Server already stopped
|
|
2160
|
+
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;flex-direction:column;gap:1rem;"><h1 style="color:#2dd4bf;">Pakt</h1><p style="color:#888;">Server stopped. You can close this tab.</p></div>';
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// Render server settings from init data (no additional fetches)
|
|
2165
|
+
function renderServersFromInit(servers, globalSyncConfig) {
|
|
2166
|
+
globalSync = globalSyncConfig || {};
|
|
2167
|
+
serverData = {};
|
|
2168
|
+
|
|
2169
|
+
for (const server of servers) {
|
|
2170
|
+
const libs = server.libraries || {movie: [], show: []};
|
|
2171
|
+
serverData[server.name] = {
|
|
2172
|
+
available: {movie: libs.movie || [], show: libs.show || []},
|
|
2173
|
+
selected: {movie: server.movie_libraries || [], show: server.show_libraries || []},
|
|
2174
|
+
enabled: server.enabled,
|
|
2175
|
+
sync: server.sync || {}
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
renderServerSettings();
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// Render Plex servers stats from init data (no additional fetches)
|
|
2183
|
+
function renderPlexServersStats(servers) {
|
|
2184
|
+
const container = document.getElementById('plex-servers-stats');
|
|
2185
|
+
if (!container) return;
|
|
2186
|
+
|
|
2187
|
+
if (!servers || servers.length === 0) {
|
|
2188
|
+
container.innerHTML = '<span style="color: var(--text-dim);">No servers configured</span>';
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
let html = '';
|
|
2193
|
+
for (const server of servers) {
|
|
2194
|
+
const statusClass = server.enabled ? 'success' : 'warning';
|
|
2195
|
+
const statusText = server.enabled ? 'Enabled' : 'Disabled';
|
|
2196
|
+
|
|
2197
|
+
const libs = server.libraries || {movie: [], show: []};
|
|
2198
|
+
const movieTotal = (libs.movie || []).length;
|
|
2199
|
+
const showTotal = (libs.show || []).length;
|
|
2200
|
+
const movieSelected = (server.movie_libraries || []).length;
|
|
2201
|
+
const showSelected = (server.show_libraries || []).length;
|
|
2202
|
+
|
|
2203
|
+
// Empty selected = all enabled
|
|
2204
|
+
const movieEnabled = movieSelected === 0 ? movieTotal : movieSelected;
|
|
2205
|
+
const showEnabled = showSelected === 0 ? showTotal : showSelected;
|
|
2206
|
+
|
|
2207
|
+
const movieCount = movieEnabled === movieTotal
|
|
2208
|
+
? `${movieTotal} libraries`
|
|
2209
|
+
: `${movieTotal} libraries (${movieEnabled} enabled)`;
|
|
2210
|
+
const showCount = showEnabled === showTotal
|
|
2211
|
+
? `${showTotal} libraries`
|
|
2212
|
+
: `${showTotal} libraries (${showEnabled} enabled)`;
|
|
2213
|
+
|
|
2214
|
+
html += `<div style="margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border);">
|
|
2215
|
+
<div class="row">
|
|
2216
|
+
<span class="label">Server</span>
|
|
2217
|
+
<span class="value">${escapeHtml(server.name)}</span>
|
|
2218
|
+
</div>
|
|
2219
|
+
<div class="row">
|
|
2220
|
+
<span class="label">Status</span>
|
|
2221
|
+
<span class="value ${statusClass}">${statusText}</span>
|
|
2222
|
+
</div>
|
|
2223
|
+
<div class="row">
|
|
2224
|
+
<span class="label">Movies</span>
|
|
2225
|
+
<span class="value">${movieCount}</span>
|
|
2226
|
+
</div>
|
|
2227
|
+
<div class="row">
|
|
2228
|
+
<span class="label">Shows</span>
|
|
2229
|
+
<span class="value">${showCount}</span>
|
|
2230
|
+
</div>
|
|
2231
|
+
</div>`;
|
|
2232
|
+
}
|
|
2233
|
+
// Remove last border
|
|
2234
|
+
container.innerHTML = html.replace(/border-bottom: 1px solid var\(--border\);">([^<]*<\/div>\s*)$/, '">$1');
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// Fast initial load using single combined endpoint
|
|
2238
|
+
(async function initialLoad() {
|
|
2239
|
+
try {
|
|
2240
|
+
const resp = await fetch('/api/init');
|
|
2241
|
+
const data = await resp.json();
|
|
2242
|
+
|
|
2243
|
+
// Apply status
|
|
2244
|
+
isConfigured = data.status.trakt_authenticated && data.status.plex_configured;
|
|
2245
|
+
const badge = document.getElementById('status-badge');
|
|
2246
|
+
|
|
2247
|
+
if (data.status.sync_running) {
|
|
2248
|
+
badge.textContent = 'Syncing...';
|
|
2249
|
+
badge.className = 'status-badge running';
|
|
2250
|
+
document.getElementById('sync-btn').disabled = true;
|
|
2251
|
+
document.getElementById('sync-btn').textContent = 'Syncing...';
|
|
2252
|
+
document.getElementById('dry-run-btn').classList.add('hidden');
|
|
2253
|
+
document.getElementById('cancel-btn').classList.remove('hidden');
|
|
2254
|
+
document.getElementById('verbose-logging').disabled = true;
|
|
2255
|
+
document.getElementById('progress-container').classList.remove('hidden');
|
|
2256
|
+
if (!syncPolling) {
|
|
2257
|
+
syncPolling = true;
|
|
2258
|
+
pollSyncStatus();
|
|
2259
|
+
}
|
|
2260
|
+
} else if (isConfigured) {
|
|
2261
|
+
badge.textContent = 'Ready';
|
|
2262
|
+
badge.className = 'status-badge ok';
|
|
2263
|
+
} else {
|
|
2264
|
+
badge.textContent = 'Setup';
|
|
2265
|
+
badge.className = 'status-badge error';
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// Show wizard or dashboard
|
|
2269
|
+
if (isConfigured) {
|
|
2270
|
+
document.getElementById('wizard').classList.add('hidden');
|
|
2271
|
+
document.getElementById('dashboard').classList.remove('hidden');
|
|
2272
|
+
document.getElementById('main-nav').classList.remove('hidden');
|
|
2273
|
+
document.getElementById('settings-btn').classList.remove('hidden');
|
|
2274
|
+
|
|
2275
|
+
// Apply config
|
|
2276
|
+
const sync = data.config.sync;
|
|
2277
|
+
document.getElementById('watched-plex-to-trakt').checked = sync.watched_plex_to_trakt;
|
|
2278
|
+
document.getElementById('watched-trakt-to-plex').checked = sync.watched_trakt_to_plex;
|
|
2279
|
+
document.getElementById('ratings-plex-to-trakt').checked = sync.ratings_plex_to_trakt;
|
|
2280
|
+
document.getElementById('ratings-trakt-to-plex').checked = sync.ratings_trakt_to_plex;
|
|
2281
|
+
document.getElementById('collection-plex-to-trakt').checked = sync.collection_plex_to_trakt;
|
|
2282
|
+
document.getElementById('watchlist-plex-to-trakt').checked = sync.watchlist_plex_to_trakt;
|
|
2283
|
+
document.getElementById('watchlist-trakt-to-plex').checked = sync.watchlist_trakt_to_plex;
|
|
2284
|
+
|
|
2285
|
+
// Apply scheduler
|
|
2286
|
+
const schedEnabledEl = document.getElementById('scheduler-enabled');
|
|
2287
|
+
const schedIntervalEl = document.getElementById('scheduler-interval');
|
|
2288
|
+
if (schedEnabledEl) schedEnabledEl.checked = data.config.scheduler.enabled;
|
|
2289
|
+
if (schedIntervalEl) schedIntervalEl.value = data.config.scheduler.interval_hours;
|
|
2290
|
+
|
|
2291
|
+
// Apply Trakt account
|
|
2292
|
+
if (data.trakt_account && data.trakt_account.status === 'ok') {
|
|
2293
|
+
document.getElementById('trakt-status').textContent = data.trakt_account.is_vip ? 'VIP' : 'Free';
|
|
2294
|
+
document.getElementById('trakt-status').className = 'value success';
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// Render servers with libraries (from init data)
|
|
2298
|
+
if (data.servers && data.servers.length > 0) {
|
|
2299
|
+
renderServersFromInit(data.servers, data.config.sync);
|
|
2300
|
+
renderPlexServersStats(data.servers);
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
// Apply last sync info
|
|
2304
|
+
if (data.status.last_run) {
|
|
2305
|
+
document.getElementById('last-run').textContent = new Date(data.status.last_run).toLocaleString();
|
|
2306
|
+
}
|
|
2307
|
+
if (data.status.last_result) {
|
|
2308
|
+
document.getElementById('added-trakt').textContent = data.status.last_result.added_to_trakt || 0;
|
|
2309
|
+
document.getElementById('added-plex').textContent = data.status.last_result.added_to_plex || 0;
|
|
2310
|
+
document.getElementById('ratings-synced').textContent = data.status.last_result.ratings_synced || 0;
|
|
2311
|
+
}
|
|
2312
|
+
} else {
|
|
2313
|
+
wizardInProgress = true;
|
|
2314
|
+
document.getElementById('wizard').classList.remove('hidden');
|
|
2315
|
+
document.getElementById('dashboard').classList.add('hidden');
|
|
2316
|
+
}
|
|
2317
|
+
} catch (e) {
|
|
2318
|
+
console.error('Init failed:', e);
|
|
2319
|
+
// Fall back to old method
|
|
2320
|
+
loadStatus();
|
|
2321
|
+
loadConfig();
|
|
2322
|
+
}
|
|
2323
|
+
})();
|
|
2324
|
+
setInterval(loadStatus, 5000);
|
|
2325
|
+
</script>
|
|
2326
|
+
</body>
|
|
2327
|
+
</html>
|