@muhammedaksam/easiarr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,549 @@
1
+ /**
2
+ * App Configurator Screen
3
+ * Configures *arr apps via API - sets root folders and download clients
4
+ */
5
+
6
+ import {
7
+ BoxRenderable,
8
+ CliRenderer,
9
+ TextRenderable,
10
+ InputRenderable,
11
+ InputRenderableEvents,
12
+ KeyEvent,
13
+ } from "@opentui/core"
14
+ import { existsSync, readFileSync } from "node:fs"
15
+ import { writeFile, readFile } from "node:fs/promises"
16
+ import { join } from "node:path"
17
+ import { createPageLayout } from "../components/PageLayout"
18
+ import { EasiarrConfig, AppId } from "../../config/schema"
19
+ import { getApp } from "../../apps/registry"
20
+ import { ArrApiClient, createQBittorrentConfig, createSABnzbdConfig } from "../../api/arr-api"
21
+ import { getComposePath } from "../../config/manager"
22
+
23
+ interface ConfigResult {
24
+ appId: AppId
25
+ appName: string
26
+ status: "pending" | "configuring" | "success" | "error" | "skipped"
27
+ message?: string
28
+ }
29
+
30
+ type Step = "configure" | "qbittorrent" | "sabnzbd" | "done"
31
+
32
+ export class AppConfigurator extends BoxRenderable {
33
+ private config: EasiarrConfig
34
+ private cliRenderer: CliRenderer
35
+ private keyHandler!: (key: KeyEvent) => void
36
+ private results: ConfigResult[] = []
37
+ private currentStep: Step = "configure"
38
+ private contentBox!: BoxRenderable
39
+ private pageContainer!: BoxRenderable
40
+
41
+ // Download client credentials
42
+ private qbHost = "qbittorrent"
43
+ private qbPort = 8080
44
+ private qbUser = "admin"
45
+ private qbPass = ""
46
+ private sabHost = "sabnzbd"
47
+ private sabPort = 8080
48
+ private sabApiKey = ""
49
+
50
+ // Check which download clients are enabled
51
+ private hasQBittorrent = false
52
+ private hasSABnzbd = false
53
+
54
+ constructor(
55
+ renderer: CliRenderer,
56
+ config: EasiarrConfig,
57
+ private onBack: () => void
58
+ ) {
59
+ super(renderer, {
60
+ id: "app-configurator",
61
+ width: "100%",
62
+ height: "100%",
63
+ backgroundColor: "#111111",
64
+ zIndex: 200,
65
+ })
66
+ this.cliRenderer = renderer
67
+ this.config = config
68
+
69
+ // Check enabled download clients
70
+ this.hasQBittorrent = config.apps.some((a) => a.id === "qbittorrent" && a.enabled)
71
+ this.hasSABnzbd = config.apps.some((a) => a.id === "sabnzbd" && a.enabled)
72
+
73
+ // Load saved credentials from .env
74
+ this.loadSavedCredentials()
75
+
76
+ this.runConfiguration()
77
+ }
78
+
79
+ private loadSavedCredentials() {
80
+ try {
81
+ const envPath = getComposePath().replace("docker-compose.yml", ".env")
82
+ if (!existsSync(envPath)) return
83
+
84
+ const content = readFileSync(envPath, "utf-8")
85
+ content.split("\n").forEach((line) => {
86
+ const [key, ...val] = line.split("=")
87
+ if (key && val.length > 0) {
88
+ const value = val.join("=").trim()
89
+ if (key.trim() === "QBITTORRENT_PASSWORD") {
90
+ this.qbPass = value
91
+ } else if (key.trim() === "SABNZBD_API_KEY") {
92
+ this.sabApiKey = value
93
+ }
94
+ }
95
+ })
96
+ } catch {
97
+ // Ignore errors
98
+ }
99
+ }
100
+
101
+ private async runConfiguration() {
102
+ // Initialize results for apps that have rootFolder
103
+ for (const appConfig of this.config.apps) {
104
+ if (!appConfig.enabled) continue
105
+ const appDef = getApp(appConfig.id)
106
+ if (!appDef?.rootFolder) continue
107
+
108
+ this.results.push({
109
+ appId: appConfig.id,
110
+ appName: appDef.name,
111
+ status: "pending",
112
+ })
113
+ }
114
+
115
+ this.renderConfigProgress()
116
+
117
+ // Configure each app
118
+ for (let i = 0; i < this.results.length; i++) {
119
+ const result = this.results[i]
120
+ result.status = "configuring"
121
+ this.updateDisplay()
122
+
123
+ try {
124
+ await this.configureApp(result.appId)
125
+ result.status = "success"
126
+ result.message = "Root folder configured"
127
+ } catch (e) {
128
+ result.status = "error"
129
+ result.message = e instanceof Error ? e.message : String(e)
130
+ }
131
+ this.updateDisplay()
132
+ }
133
+
134
+ // After root folders, prompt for download clients if needed
135
+ if (this.hasQBittorrent || this.hasSABnzbd) {
136
+ if (this.hasQBittorrent) {
137
+ this.currentStep = "qbittorrent"
138
+ this.renderQBittorrentPrompt()
139
+ } else if (this.hasSABnzbd) {
140
+ this.currentStep = "sabnzbd"
141
+ this.renderSABnzbdPrompt()
142
+ }
143
+ } else {
144
+ this.currentStep = "done"
145
+ this.renderDone()
146
+ }
147
+ }
148
+
149
+ private async configureApp(appId: AppId): Promise<void> {
150
+ const appDef = getApp(appId)
151
+ if (!appDef?.rootFolder || !appDef.apiKeyMeta) {
152
+ throw new Error("Missing configuration")
153
+ }
154
+
155
+ // Get API key from config file
156
+ const apiKey = this.extractApiKey(appId)
157
+ if (!apiKey) {
158
+ throw new Error("API key not found - start container first")
159
+ }
160
+
161
+ // Wait for app to be healthy
162
+ const port = this.config.apps.find((a) => a.id === appId)?.port || appDef.defaultPort
163
+ const client = new ArrApiClient("localhost", port, apiKey, appDef.rootFolder.apiVersion)
164
+
165
+ // Retry health check a few times
166
+ let healthy = false
167
+ for (let i = 0; i < 3; i++) {
168
+ healthy = await client.isHealthy()
169
+ if (healthy) break
170
+ await new Promise((r) => setTimeout(r, 1000))
171
+ }
172
+
173
+ if (!healthy) {
174
+ throw new Error("App not responding - start containers first")
175
+ }
176
+
177
+ // Check if root folder already exists
178
+ const existingFolders = await client.getRootFolders()
179
+ const alreadyExists = existingFolders.some((f) => f.path === appDef.rootFolder!.path)
180
+
181
+ if (alreadyExists) {
182
+ throw new Error("Already configured")
183
+ }
184
+
185
+ // Add root folder
186
+ await client.addRootFolder(appDef.rootFolder.path)
187
+ }
188
+
189
+ private extractApiKey(appId: AppId): string | null {
190
+ const appDef = getApp(appId)
191
+ if (!appDef?.apiKeyMeta) return null
192
+
193
+ const volumes = appDef.volumes(this.config.rootDir)
194
+ if (volumes.length === 0) return null
195
+
196
+ const parts = volumes[0].split(":")
197
+ const hostPath = parts[0]
198
+ const configFilePath = join(hostPath, appDef.apiKeyMeta.configFile)
199
+
200
+ if (!existsSync(configFilePath)) return null
201
+
202
+ const content = readFileSync(configFilePath, "utf-8")
203
+
204
+ if (appDef.apiKeyMeta.parser === "regex") {
205
+ const regex = new RegExp(appDef.apiKeyMeta.selector)
206
+ const match = regex.exec(content)
207
+ return match?.[1] || null
208
+ }
209
+
210
+ return null
211
+ }
212
+
213
+ private renderConfigProgress() {
214
+ this.clear()
215
+
216
+ const { container, content } = createPageLayout(this.cliRenderer, {
217
+ title: "Configure Apps",
218
+ stepInfo: "Setting up root folders",
219
+ footerHint: "Please wait...",
220
+ })
221
+ this.pageContainer = container
222
+ this.contentBox = content
223
+ this.add(container)
224
+
225
+ this.updateDisplay()
226
+ }
227
+
228
+ private updateDisplay() {
229
+ // Clear content and rebuild - remove all children from contentBox
230
+ const contentChildren = [...this.contentBox.getChildren()]
231
+ for (const child of contentChildren) {
232
+ if (child.id) {
233
+ try {
234
+ this.contentBox.remove(child.id)
235
+ } catch {
236
+ /* ignore */
237
+ }
238
+ }
239
+ }
240
+
241
+ // Header
242
+ this.contentBox.add(
243
+ new TextRenderable(this.cliRenderer, {
244
+ content: "Configuring *arr applications...\n",
245
+ fg: "#4a9eff",
246
+ })
247
+ )
248
+
249
+ // Results
250
+ for (const result of this.results) {
251
+ const icon =
252
+ result.status === "pending"
253
+ ? "⏳"
254
+ : result.status === "configuring"
255
+ ? "🔄"
256
+ : result.status === "success"
257
+ ? "✓"
258
+ : result.status === "skipped"
259
+ ? "⏭"
260
+ : "✗"
261
+
262
+ const color =
263
+ result.status === "success"
264
+ ? "#50fa7b"
265
+ : result.status === "error"
266
+ ? "#ff5555"
267
+ : result.status === "skipped"
268
+ ? "#6272a4"
269
+ : "#f1fa8c"
270
+
271
+ const message = result.message ? ` - ${result.message}` : ""
272
+
273
+ this.contentBox.add(
274
+ new TextRenderable(this.cliRenderer, {
275
+ content: `${icon} ${result.appName.padEnd(15)} ${message}`,
276
+ fg: color,
277
+ })
278
+ )
279
+ }
280
+ }
281
+
282
+ private renderQBittorrentPrompt() {
283
+ this.clear()
284
+
285
+ const { container, content } = createPageLayout(this.cliRenderer, {
286
+ title: "Configure Apps",
287
+ stepInfo: "qBittorrent Credentials",
288
+ footerHint: "Enter credentials from qBittorrent WebUI Esc Skip",
289
+ })
290
+ this.pageContainer = container
291
+ this.add(container)
292
+
293
+ content.add(
294
+ new TextRenderable(this.cliRenderer, {
295
+ content: "Enter qBittorrent credentials (from Settings → WebUI):\n",
296
+ fg: "#4a9eff",
297
+ })
298
+ )
299
+
300
+ // Password input - pre-fill with saved value
301
+ content.add(new TextRenderable(this.cliRenderer, { content: "Password:", fg: "#aaaaaa" }))
302
+ const passInput = new InputRenderable(this.cliRenderer, {
303
+ id: "qb-pass-input",
304
+ width: 30,
305
+ placeholder: "WebUI Password",
306
+ value: this.qbPass,
307
+ focusedBackgroundColor: "#1a1a1a",
308
+ })
309
+ content.add(passInput)
310
+
311
+ passInput.focus()
312
+
313
+ // Handle Enter via SUBMIT event
314
+ passInput.on(InputRenderableEvents.ENTER, () => {
315
+ this.qbPass = passInput.value
316
+ if (this.keyHandler) this.cliRenderer.keyInput.off("keypress", this.keyHandler)
317
+ passInput.blur()
318
+ this.addDownloadClients("qbittorrent")
319
+ })
320
+
321
+ // Handle Escape via keypress
322
+ this.keyHandler = (key: KeyEvent) => {
323
+ if (key.name === "escape") {
324
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
325
+ passInput.blur()
326
+ if (this.hasSABnzbd) {
327
+ this.currentStep = "sabnzbd"
328
+ this.renderSABnzbdPrompt()
329
+ } else {
330
+ this.currentStep = "done"
331
+ this.renderDone()
332
+ }
333
+ }
334
+ }
335
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
336
+ }
337
+
338
+ private renderSABnzbdPrompt() {
339
+ this.clear()
340
+
341
+ const { container, content } = createPageLayout(this.cliRenderer, {
342
+ title: "Configure Apps",
343
+ stepInfo: "SABnzbd Credentials",
344
+ footerHint: "Enter API key from SABnzbd Config → General Esc Skip",
345
+ })
346
+ this.pageContainer = container
347
+ this.add(container)
348
+
349
+ content.add(
350
+ new TextRenderable(this.cliRenderer, {
351
+ content: "Enter SABnzbd API Key (from Config → General → API Key):\n",
352
+ fg: "#4a9eff",
353
+ })
354
+ )
355
+
356
+ // API key input - pre-fill with saved value
357
+ content.add(new TextRenderable(this.cliRenderer, { content: "API Key:", fg: "#aaaaaa" }))
358
+ const keyInput = new InputRenderable(this.cliRenderer, {
359
+ id: "sab-key-input",
360
+ width: 40,
361
+ placeholder: "SABnzbd API Key",
362
+ value: this.sabApiKey,
363
+ focusedBackgroundColor: "#1a1a1a",
364
+ })
365
+ content.add(keyInput)
366
+
367
+ keyInput.focus()
368
+
369
+ // Handle Enter via SUBMIT event
370
+ keyInput.on(InputRenderableEvents.ENTER, () => {
371
+ this.sabApiKey = keyInput.value
372
+ if (this.keyHandler) this.cliRenderer.keyInput.off("keypress", this.keyHandler)
373
+ keyInput.blur()
374
+ this.addDownloadClients("sabnzbd")
375
+ })
376
+
377
+ // Handle Escape via keypress
378
+ this.keyHandler = (key: KeyEvent) => {
379
+ if (key.name === "escape") {
380
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
381
+ keyInput.blur()
382
+ this.currentStep = "done"
383
+ this.renderDone()
384
+ }
385
+ }
386
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
387
+ }
388
+
389
+ private async addDownloadClients(type: "qbittorrent" | "sabnzbd") {
390
+ // Add download client to all *arr apps
391
+ const servarrApps = this.config.apps.filter((a) => {
392
+ const def = getApp(a.id)
393
+ return a.enabled && def?.rootFolder
394
+ })
395
+
396
+ for (const appConfig of servarrApps) {
397
+ const appDef = getApp(appConfig.id)
398
+ if (!appDef?.rootFolder || !appDef.apiKeyMeta) continue
399
+
400
+ const apiKey = this.extractApiKey(appConfig.id)
401
+ if (!apiKey) continue
402
+
403
+ const port = appConfig.port || appDef.defaultPort
404
+ const client = new ArrApiClient("localhost", port, apiKey, appDef.rootFolder.apiVersion)
405
+
406
+ try {
407
+ if (type === "qbittorrent") {
408
+ const config = createQBittorrentConfig(this.qbHost, this.qbPort, this.qbUser, this.qbPass, appConfig.id)
409
+ await client.addDownloadClient(config)
410
+ } else {
411
+ const config = createSABnzbdConfig(this.sabHost, this.sabPort, this.sabApiKey, appConfig.id)
412
+ await client.addDownloadClient(config)
413
+ }
414
+ } catch {
415
+ // Ignore errors - client may already exist
416
+ }
417
+ }
418
+
419
+ // Save credentials to .env
420
+ await this.saveCredentialsToEnv(type)
421
+
422
+ // Move to next step
423
+ if (type === "qbittorrent" && this.hasSABnzbd) {
424
+ this.currentStep = "sabnzbd"
425
+ this.renderSABnzbdPrompt()
426
+ } else {
427
+ this.currentStep = "done"
428
+ this.renderDone()
429
+ }
430
+ }
431
+
432
+ private async saveCredentialsToEnv(type: "qbittorrent" | "sabnzbd") {
433
+ try {
434
+ const envPath = getComposePath().replace("docker-compose.yml", ".env")
435
+
436
+ // Read existing .env if present
437
+ const currentEnv: Record<string, string> = {}
438
+ if (existsSync(envPath)) {
439
+ const content = await readFile(envPath, "utf-8")
440
+ content.split("\n").forEach((line) => {
441
+ const [key, ...val] = line.split("=")
442
+ if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
443
+ })
444
+ }
445
+
446
+ // Add credentials
447
+ if (type === "qbittorrent" && this.qbPass) {
448
+ currentEnv["QBITTORRENT_PASSWORD"] = this.qbPass
449
+ } else if (type === "sabnzbd" && this.sabApiKey) {
450
+ currentEnv["SABNZBD_API_KEY"] = this.sabApiKey
451
+ }
452
+
453
+ // Reconstruct .env content
454
+ const envContent = Object.entries(currentEnv)
455
+ .map(([k, v]) => `${k}=${v}`)
456
+ .join("\n")
457
+
458
+ await writeFile(envPath, envContent, "utf-8")
459
+ } catch {
460
+ // Ignore errors - not critical
461
+ }
462
+ }
463
+
464
+ private renderDone() {
465
+ this.clear()
466
+
467
+ const { container, content } = createPageLayout(this.cliRenderer, {
468
+ title: "Configure Apps",
469
+ stepInfo: "Complete",
470
+ footerHint: "Press any key to return",
471
+ })
472
+ this.add(container)
473
+
474
+ const successCount = this.results.filter((r) => r.status === "success").length
475
+ const errorCount = this.results.filter((r) => r.status === "error").length
476
+
477
+ content.add(
478
+ new TextRenderable(this.cliRenderer, {
479
+ content: "Configuration complete!\n",
480
+ fg: "#50fa7b",
481
+ })
482
+ )
483
+
484
+ content.add(
485
+ new TextRenderable(this.cliRenderer, {
486
+ content: `✓ ${successCount} app(s) configured`,
487
+ fg: "#50fa7b",
488
+ })
489
+ )
490
+
491
+ if (errorCount > 0) {
492
+ content.add(
493
+ new TextRenderable(this.cliRenderer, {
494
+ content: `✗ ${errorCount} app(s) had errors (see above)`,
495
+ fg: "#ff5555",
496
+ })
497
+ )
498
+ }
499
+
500
+ content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
501
+
502
+ // Show results summary
503
+ for (const result of this.results) {
504
+ const icon = result.status === "success" ? "✓" : result.status === "skipped" ? "⏭" : "✗"
505
+ const color = result.status === "success" ? "#50fa7b" : result.status === "skipped" ? "#6272a4" : "#ff5555"
506
+ const message = result.message ? ` - ${result.message}` : ""
507
+
508
+ content.add(
509
+ new TextRenderable(this.cliRenderer, {
510
+ content: `${icon} ${result.appName}${message}`,
511
+ fg: color,
512
+ })
513
+ )
514
+ }
515
+
516
+ this.keyHandler = () => {
517
+ this.destroy()
518
+ this.onBack()
519
+ }
520
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
521
+ }
522
+
523
+ private clear() {
524
+ // Remove all children
525
+ const children = [...this.getChildren()]
526
+ for (const child of children) {
527
+ if (child.id) {
528
+ try {
529
+ this.remove(child.id)
530
+ } catch {
531
+ /* ignore */
532
+ }
533
+ }
534
+ }
535
+ }
536
+
537
+ public destroy() {
538
+ if (this.keyHandler) {
539
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
540
+ }
541
+ if (this.parent && this.id) {
542
+ try {
543
+ this.parent.remove(this.id)
544
+ } catch {
545
+ /* ignore */
546
+ }
547
+ }
548
+ }
549
+ }