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.
@@ -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>