@muhammedaksam/easiarr 0.8.3 → 0.8.5

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,612 @@
1
+ /**
2
+ * Jellyseerr Setup Screen
3
+ * Automates the Jellyseerr setup wizard via API
4
+ */
5
+
6
+ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
7
+ import { createPageLayout } from "../components/PageLayout"
8
+ import { EasiarrConfig } from "../../config/schema"
9
+ import { JellyseerrClient } from "../../api/jellyseerr-api"
10
+ import { getApp } from "../../apps/registry"
11
+ import { readEnvSync, writeEnvSync } from "../../utils/env"
12
+ import { debugLog } from "../../utils/debug"
13
+
14
+ interface SetupResult {
15
+ name: string
16
+ status: "pending" | "configuring" | "success" | "error" | "skipped"
17
+ message?: string
18
+ }
19
+
20
+ type Step = "menu" | "running" | "done"
21
+
22
+ export class JellyseerrSetup extends BoxRenderable {
23
+ private config: EasiarrConfig
24
+ private cliRenderer: CliRenderer
25
+ private onBack: () => void
26
+ private keyHandler!: (key: KeyEvent) => void
27
+ private results: SetupResult[] = []
28
+ private currentStep: Step = "menu"
29
+ private contentBox!: BoxRenderable
30
+ private menuIndex = 0
31
+ private jellyseerrClient: JellyseerrClient | null = null
32
+ private mediaServerType: "jellyfin" | "plex" | "emby" | null = null
33
+
34
+ constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
35
+ const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
36
+ title: "Jellyseerr Setup",
37
+ stepInfo: "Configure Jellyseerr via API",
38
+ footerHint: [
39
+ { type: "key", key: "↑↓", value: "Navigate" },
40
+ { type: "key", key: "Enter", value: "Select" },
41
+ { type: "key", key: "Esc", value: "Back" },
42
+ ],
43
+ })
44
+ super(cliRenderer, { width: "100%", height: "100%" })
45
+ this.add(pageContainer)
46
+
47
+ this.config = config
48
+ this.cliRenderer = cliRenderer
49
+ this.onBack = onBack
50
+ this.contentBox = contentBox
51
+
52
+ this.initClient()
53
+ this.detectMediaServer()
54
+ this.initKeyHandler()
55
+ this.refreshContent()
56
+ }
57
+
58
+ private initClient(): void {
59
+ const jellyseerrConfig = this.config.apps.find((a) => a.id === "jellyseerr")
60
+ if (jellyseerrConfig?.enabled) {
61
+ const port = jellyseerrConfig.port || 5055
62
+ this.jellyseerrClient = new JellyseerrClient("localhost", port)
63
+ }
64
+ }
65
+
66
+ private detectMediaServer(): void {
67
+ // Check which media server is enabled
68
+ const jellyfin = this.config.apps.find((a) => a.id === "jellyfin" && a.enabled)
69
+ const plex = this.config.apps.find((a) => a.id === "plex" && a.enabled)
70
+
71
+ if (jellyfin) this.mediaServerType = "jellyfin"
72
+ else if (plex) this.mediaServerType = "plex"
73
+ }
74
+
75
+ private initKeyHandler(): void {
76
+ this.keyHandler = (key: KeyEvent) => {
77
+ debugLog("Jellyseerr", `Key: ${key.name}, step=${this.currentStep}`)
78
+
79
+ if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
80
+ if (this.currentStep === "menu") {
81
+ this.cleanup()
82
+ } else if (this.currentStep === "done") {
83
+ this.currentStep = "menu"
84
+ this.refreshContent()
85
+ }
86
+ return
87
+ }
88
+
89
+ if (this.currentStep === "menu") {
90
+ this.handleMenuKeys(key)
91
+ } else if (this.currentStep === "done") {
92
+ if (key.name === "return" || key.name === "escape") {
93
+ this.currentStep = "menu"
94
+ this.refreshContent()
95
+ }
96
+ }
97
+ }
98
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
99
+ debugLog("Jellyseerr", "Key handler registered")
100
+ }
101
+
102
+ private handleMenuKeys(key: KeyEvent): void {
103
+ const menuItems = this.getMenuItems()
104
+
105
+ if (key.name === "up" && this.menuIndex > 0) {
106
+ this.menuIndex--
107
+ this.refreshContent()
108
+ } else if (key.name === "down" && this.menuIndex < menuItems.length - 1) {
109
+ this.menuIndex++
110
+ this.refreshContent()
111
+ } else if (key.name === "return") {
112
+ this.executeMenuItem(this.menuIndex)
113
+ }
114
+ }
115
+
116
+ private getMenuItems(): { name: string; description: string; action: () => void }[] {
117
+ return [
118
+ {
119
+ name: "🚀 Run Setup Wizard",
120
+ description: "Configure media server and create admin user",
121
+ action: () => this.runSetupWizard(),
122
+ },
123
+ {
124
+ name: "📚 Sync Libraries",
125
+ description: "Sync and enable libraries from media server",
126
+ action: () => this.syncLibraries(),
127
+ },
128
+ {
129
+ name: "🔗 Configure Radarr/Sonarr",
130
+ description: "Connect *arr apps for request automation",
131
+ action: () => this.configureArrApps(),
132
+ },
133
+ {
134
+ name: "↩️ Back",
135
+ description: "Return to main menu",
136
+ action: () => this.cleanup(),
137
+ },
138
+ ]
139
+ }
140
+
141
+ private executeMenuItem(index: number): void {
142
+ const items = this.getMenuItems()
143
+ if (index >= 0 && index < items.length) {
144
+ items[index].action()
145
+ }
146
+ }
147
+
148
+ private async runSetupWizard(): Promise<void> {
149
+ if (!this.jellyseerrClient) {
150
+ this.results = [{ name: "Jellyseerr", status: "error", message: "Not enabled in config" }]
151
+ this.currentStep = "done"
152
+ this.refreshContent()
153
+ return
154
+ }
155
+
156
+ if (!this.mediaServerType) {
157
+ this.results = [{ name: "Media Server", status: "error", message: "No media server enabled" }]
158
+ this.currentStep = "done"
159
+ this.refreshContent()
160
+ return
161
+ }
162
+
163
+ this.currentStep = "running"
164
+ this.results = [
165
+ { name: "Check status", status: "configuring" },
166
+ { name: "Authenticate", status: "pending" },
167
+ { name: "Configure media server", status: "pending" },
168
+ { name: "Sync libraries", status: "pending" },
169
+ { name: "Save API key", status: "pending" },
170
+ { name: "Finalize setup", status: "pending" },
171
+ ]
172
+ this.refreshContent()
173
+
174
+ try {
175
+ // Step 1: Check if already initialized
176
+ const isInit = await this.jellyseerrClient.isInitialized()
177
+ if (isInit) {
178
+ this.results[0].status = "skipped"
179
+ this.results[0].message = "Already initialized"
180
+ this.results.slice(1).forEach((r) => {
181
+ r.status = "skipped"
182
+ r.message = "Setup already complete"
183
+ })
184
+ this.currentStep = "done"
185
+ this.refreshContent()
186
+ return
187
+ }
188
+ this.results[0].status = "success"
189
+ this.results[0].message = "Setup needed"
190
+ this.refreshContent()
191
+
192
+ // Get credentials
193
+ const env = readEnvSync()
194
+ const username = env["USERNAME_GLOBAL"] || "admin"
195
+ const password = env["PASSWORD_GLOBAL"] || "Ch4ng3m3!1234securityReasons"
196
+
197
+ if (this.mediaServerType === "jellyfin") {
198
+ const jellyfinDef = getApp("jellyfin")
199
+ // Use internal port for container-to-container communication (always 8096)
200
+ const internalPort = jellyfinDef?.internalPort || jellyfinDef?.defaultPort || 8096
201
+ const jellyfinHost = "jellyfin" // Hostname only for auth
202
+ const jellyfinFullUrl = `http://${jellyfinHost}:${internalPort}` // Full URL for settings
203
+ const userEmail = `${username}@easiarr.local`
204
+
205
+ debugLog("Jellyseerr", `Connecting to Jellyfin at ${jellyfinFullUrl}`)
206
+
207
+ try {
208
+ // Step 2: Authenticate with Jellyseerr (creates admin user AND gets session cookie)
209
+ this.results[1].status = "configuring"
210
+ this.refreshContent()
211
+ await this.jellyseerrClient.authenticateJellyfin(username, password, jellyfinHost, internalPort, userEmail)
212
+ this.results[1].status = "success"
213
+ this.results[1].message = `User: ${username}`
214
+ this.refreshContent()
215
+
216
+ // Step 3: Configure media server (now we have the session cookie)
217
+ this.results[2].status = "configuring"
218
+ this.refreshContent()
219
+ await this.jellyseerrClient.updateJellyfinSettings({
220
+ hostname: jellyfinFullUrl,
221
+ adminUser: username,
222
+ adminPass: password,
223
+ })
224
+ this.results[2].status = "success"
225
+ this.results[2].message = `Jellyfin @ ${jellyfinHost}`
226
+ this.refreshContent()
227
+ } catch (error: unknown) {
228
+ const err = error instanceof Error ? error : new Error(String(error))
229
+ debugLog("Jellyseerr", `Auth failed: ${err.message}`)
230
+ this.results[1].status = "error"
231
+ this.results[1].message = "Auth Failed"
232
+
233
+ if (err.message.includes("NO_ADMIN_USER")) {
234
+ this.results[2].message = "Jellyfin user not Admin"
235
+ this.results[2].status = "error"
236
+ // Wait for user to read the message
237
+ await new Promise((resolve) => setTimeout(resolve, 8000))
238
+ } else if (err.message.includes("401")) {
239
+ this.results[2].message = "Invalid Credentials"
240
+ this.results[2].status = "error"
241
+ await new Promise((resolve) => setTimeout(resolve, 8000))
242
+ } else {
243
+ this.results[2].message = "Connection Error"
244
+ this.results[2].status = "error"
245
+ await new Promise((resolve) => setTimeout(resolve, 5000))
246
+ }
247
+ throw err // Re-throw to stop the wizard
248
+ }
249
+ } else {
250
+ // Plex/Emby - skip for now, needs token-based auth
251
+ this.results[1].status = "skipped"
252
+ this.results[1].message = "Token auth needed"
253
+ this.results[2].status = "skipped"
254
+ this.results[2].message = `${this.mediaServerType} requires manual setup`
255
+ this.refreshContent()
256
+ }
257
+
258
+ // Step 4: Sync libraries
259
+ this.results[3].status = "configuring"
260
+ this.refreshContent()
261
+ try {
262
+ const libraries = await this.jellyseerrClient.syncJellyfinLibraries()
263
+ const libraryIds = libraries.map((lib) => lib.id)
264
+ if (libraryIds.length > 0) {
265
+ await this.jellyseerrClient.enableLibraries(libraryIds)
266
+ }
267
+ this.results[3].status = "success"
268
+ this.results[3].message = `${libraries.length} libraries synced`
269
+ } catch {
270
+ this.results[3].status = "error"
271
+ this.results[3].message = "Library sync failed"
272
+ }
273
+ this.refreshContent()
274
+
275
+ // Step 5: Save API key
276
+ this.results[4].status = "configuring"
277
+ this.refreshContent()
278
+ try {
279
+ const mainSettings = await this.jellyseerrClient.getMainSettings()
280
+ if (mainSettings.apiKey) {
281
+ env["API_KEY_JELLYSEERR"] = mainSettings.apiKey
282
+ writeEnvSync(env)
283
+ this.results[4].status = "success"
284
+ this.results[4].message = `Key: ${mainSettings.apiKey.substring(0, 8)}...`
285
+ } else {
286
+ this.results[4].status = "error"
287
+ this.results[4].message = "No API key returned"
288
+ }
289
+ } catch {
290
+ this.results[4].status = "error"
291
+ this.results[4].message = "Failed to get API key"
292
+ }
293
+ this.refreshContent()
294
+
295
+ // Step 6: Initialize (finalize setup wizard)
296
+ this.results[5].status = "configuring"
297
+ this.refreshContent()
298
+ try {
299
+ await this.jellyseerrClient.initialize()
300
+ this.results[5].status = "success"
301
+ this.results[5].message = "Setup complete"
302
+ } catch {
303
+ this.results[5].status = "error"
304
+ this.results[5].message = "Failed to finalize"
305
+ }
306
+ this.refreshContent()
307
+ } catch (error) {
308
+ const current = this.results.find((r) => r.status === "configuring")
309
+ if (current) {
310
+ current.status = "error"
311
+ current.message = error instanceof Error ? error.message : String(error)
312
+ }
313
+ }
314
+
315
+ this.currentStep = "done"
316
+ this.refreshContent()
317
+ }
318
+
319
+ private async syncLibraries(): Promise<void> {
320
+ if (!this.jellyseerrClient) {
321
+ this.results = [{ name: "Jellyseerr", status: "error", message: "Not enabled" }]
322
+ this.currentStep = "done"
323
+ this.refreshContent()
324
+ return
325
+ }
326
+
327
+ this.currentStep = "running"
328
+ this.results = [
329
+ { name: "Sync libraries", status: "configuring" },
330
+ { name: "Enable all", status: "pending" },
331
+ ]
332
+ this.refreshContent()
333
+
334
+ try {
335
+ const libraries = await this.jellyseerrClient.syncJellyfinLibraries()
336
+ this.results[0].status = "success"
337
+ this.results[0].message = `Found ${libraries.length} libraries`
338
+ this.refreshContent()
339
+
340
+ this.results[1].status = "configuring"
341
+ this.refreshContent()
342
+ const libraryIds = libraries.map((lib) => lib.id)
343
+ if (libraryIds.length > 0) {
344
+ await this.jellyseerrClient.enableLibraries(libraryIds)
345
+ }
346
+ this.results[1].status = "success"
347
+ this.results[1].message = libraries.map((l) => l.name).join(", ")
348
+ this.refreshContent()
349
+ } catch (error) {
350
+ const current = this.results.find((r) => r.status === "configuring")
351
+ if (current) {
352
+ current.status = "error"
353
+ current.message = error instanceof Error ? error.message : String(error)
354
+ }
355
+ }
356
+
357
+ this.currentStep = "done"
358
+ this.refreshContent()
359
+ }
360
+
361
+ private async configureArrApps(): Promise<void> {
362
+ if (!this.jellyseerrClient) {
363
+ this.results = [{ name: "Jellyseerr", status: "error", message: "Not enabled" }]
364
+ this.currentStep = "done"
365
+ this.refreshContent()
366
+ return
367
+ }
368
+
369
+ this.currentStep = "running"
370
+ this.results = []
371
+
372
+ const env = readEnvSync()
373
+
374
+ // Check for Radarr
375
+ const radarrConfig = this.config.apps.find((a) => a.id === "radarr" && a.enabled)
376
+ if (radarrConfig) {
377
+ this.results.push({ name: "Radarr", status: "pending" })
378
+ }
379
+
380
+ // Check for Sonarr
381
+ const sonarrConfig = this.config.apps.find((a) => a.id === "sonarr" && a.enabled)
382
+ if (sonarrConfig) {
383
+ this.results.push({ name: "Sonarr", status: "pending" })
384
+ }
385
+
386
+ if (this.results.length === 0) {
387
+ this.results = [{ name: "No *arr apps", status: "skipped", message: "Enable Radarr/Sonarr first" }]
388
+ this.currentStep = "done"
389
+ this.refreshContent()
390
+ return
391
+ }
392
+
393
+ this.refreshContent()
394
+
395
+ // Configure Radarr
396
+ if (radarrConfig) {
397
+ const idx = this.results.findIndex((r) => r.name === "Radarr")
398
+ this.results[idx].status = "configuring"
399
+ this.refreshContent()
400
+
401
+ const apiKey = env["API_KEY_RADARR"]
402
+ if (!apiKey) {
403
+ this.results[idx].status = "error"
404
+ this.results[idx].message = "No API key in .env"
405
+ } else {
406
+ try {
407
+ const radarrDef = getApp("radarr")
408
+ const port = radarrConfig.port || radarrDef?.defaultPort || 7878
409
+ const rootFolder = radarrDef?.rootFolder?.path || "/data/media/movies"
410
+
411
+ const result = await this.jellyseerrClient.configureRadarr("radarr", port, apiKey, rootFolder)
412
+ if (result) {
413
+ this.results[idx].status = "success"
414
+ this.results[idx].message = `Profile: ${result.activeProfileName}`
415
+ } else {
416
+ this.results[idx].status = "error"
417
+ this.results[idx].message = "Configuration failed"
418
+ }
419
+ } catch (e) {
420
+ this.results[idx].status = "error"
421
+ this.results[idx].message = e instanceof Error ? e.message : String(e)
422
+ }
423
+ }
424
+ this.refreshContent()
425
+ }
426
+
427
+ // Configure Sonarr
428
+ if (sonarrConfig) {
429
+ const idx = this.results.findIndex((r) => r.name === "Sonarr")
430
+ this.results[idx].status = "configuring"
431
+ this.refreshContent()
432
+
433
+ const apiKey = env["API_KEY_SONARR"]
434
+ if (!apiKey) {
435
+ this.results[idx].status = "error"
436
+ this.results[idx].message = "No API key in .env"
437
+ } else {
438
+ try {
439
+ const sonarrDef = getApp("sonarr")
440
+ const port = sonarrConfig.port || sonarrDef?.defaultPort || 8989
441
+ const rootFolder = sonarrDef?.rootFolder?.path || "/data/media/tv"
442
+
443
+ const result = await this.jellyseerrClient.configureSonarr("sonarr", port, apiKey, rootFolder)
444
+ if (result) {
445
+ this.results[idx].status = "success"
446
+ this.results[idx].message = `Profile: ${result.activeProfileName}`
447
+ } else {
448
+ this.results[idx].status = "error"
449
+ this.results[idx].message = "Configuration failed"
450
+ }
451
+ } catch (e) {
452
+ this.results[idx].status = "error"
453
+ this.results[idx].message = e instanceof Error ? e.message : String(e)
454
+ }
455
+ }
456
+ this.refreshContent()
457
+ }
458
+
459
+ this.currentStep = "done"
460
+ this.refreshContent()
461
+ }
462
+
463
+ private refreshContent(): void {
464
+ this.contentBox.getChildren().forEach((child) => child.destroy())
465
+
466
+ if (this.currentStep === "menu") {
467
+ this.renderMenu()
468
+ } else {
469
+ this.renderResults()
470
+ }
471
+ }
472
+
473
+ private renderMenu(): void {
474
+ // Show status
475
+ this.checkHealth()
476
+
477
+ this.contentBox.add(
478
+ new TextRenderable(this.cliRenderer, {
479
+ content: "Select an action:\n\n",
480
+ fg: "#aaaaaa",
481
+ })
482
+ )
483
+
484
+ this.getMenuItems().forEach((item, idx) => {
485
+ const pointer = idx === this.menuIndex ? "→ " : " "
486
+ const fg = idx === this.menuIndex ? "#50fa7b" : "#8be9fd"
487
+
488
+ this.contentBox.add(
489
+ new TextRenderable(this.cliRenderer, {
490
+ content: `${pointer}${item.name}\n`,
491
+ fg,
492
+ })
493
+ )
494
+ this.contentBox.add(
495
+ new TextRenderable(this.cliRenderer, {
496
+ content: ` ${item.description}\n\n`,
497
+ fg: "#6272a4",
498
+ })
499
+ )
500
+ })
501
+ }
502
+
503
+ private async checkHealth(): Promise<void> {
504
+ if (!this.jellyseerrClient) {
505
+ this.contentBox.add(
506
+ new TextRenderable(this.cliRenderer, {
507
+ content: "⚠️ Jellyseerr not enabled in config!\n\n",
508
+ fg: "#ff5555",
509
+ })
510
+ )
511
+ return
512
+ }
513
+
514
+ if (!this.mediaServerType) {
515
+ this.contentBox.add(
516
+ new TextRenderable(this.cliRenderer, {
517
+ content: "⚠️ No media server enabled (Jellyfin/Plex/Emby)\n\n",
518
+ fg: "#ff5555",
519
+ })
520
+ )
521
+ return
522
+ }
523
+
524
+ try {
525
+ const isHealthy = await this.jellyseerrClient.isHealthy()
526
+ const isInit = isHealthy ? await this.jellyseerrClient.isInitialized() : false
527
+
528
+ if (!isHealthy) {
529
+ this.contentBox.add(
530
+ new TextRenderable(this.cliRenderer, {
531
+ content: "⚠️ Jellyseerr is not reachable. Make sure the container is running.\n\n",
532
+ fg: "#ffb86c",
533
+ })
534
+ )
535
+ } else if (!isInit) {
536
+ this.contentBox.add(
537
+ new TextRenderable(this.cliRenderer, {
538
+ content: `✨ Jellyseerr needs setup. Will connect to ${this.mediaServerType}.\n\n`,
539
+ fg: "#50fa7b",
540
+ })
541
+ )
542
+ } else {
543
+ this.contentBox.add(
544
+ new TextRenderable(this.cliRenderer, {
545
+ content: "✓ Jellyseerr is running and configured.\n\n",
546
+ fg: "#50fa7b",
547
+ })
548
+ )
549
+ }
550
+ } catch {
551
+ // Ignore
552
+ }
553
+ }
554
+
555
+ private renderResults(): void {
556
+ const headerText = this.currentStep === "done" ? "Results:\n\n" : "Configuring...\n\n"
557
+ this.contentBox.add(
558
+ new TextRenderable(this.cliRenderer, {
559
+ content: headerText,
560
+ fg: this.currentStep === "done" ? "#50fa7b" : "#f1fa8c",
561
+ })
562
+ )
563
+
564
+ for (const result of this.results) {
565
+ let status = ""
566
+ let fg = "#aaaaaa"
567
+ switch (result.status) {
568
+ case "pending":
569
+ status = "⏳"
570
+ break
571
+ case "configuring":
572
+ status = "🔄"
573
+ fg = "#f1fa8c"
574
+ break
575
+ case "success":
576
+ status = "✓"
577
+ fg = "#50fa7b"
578
+ break
579
+ case "error":
580
+ status = "✗"
581
+ fg = "#ff5555"
582
+ break
583
+ case "skipped":
584
+ status = "⊘"
585
+ fg = "#6272a4"
586
+ break
587
+ }
588
+
589
+ let content = `${status} ${result.name}`
590
+ if (result.message) {
591
+ content += ` - ${result.message}`
592
+ }
593
+
594
+ this.contentBox.add(new TextRenderable(this.cliRenderer, { content: content + "\n", fg }))
595
+ }
596
+
597
+ if (this.currentStep === "done") {
598
+ this.contentBox.add(
599
+ new TextRenderable(this.cliRenderer, {
600
+ content: "\nPress Enter or Esc to continue...",
601
+ fg: "#6272a4",
602
+ })
603
+ )
604
+ }
605
+ }
606
+
607
+ private cleanup(): void {
608
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
609
+ this.destroy()
610
+ this.onBack()
611
+ }
612
+ }