@muhammedaksam/easiarr 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -38,13 +38,17 @@ export class QBittorrentClient {
38
38
  */
39
39
  async login(): Promise<boolean> {
40
40
  try {
41
+ debugLog("qBittorrent", `Logging in to ${this.baseUrl} as ${this.username}`)
41
42
  const response = await fetch(`${this.baseUrl}/api/v2/auth/login`, {
42
43
  method: "POST",
43
44
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
44
45
  body: `username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
45
46
  })
46
47
 
47
- if (!response.ok) return false
48
+ if (!response.ok) {
49
+ debugLog("qBittorrent", `Login failed: ${response.status}`)
50
+ return false
51
+ }
48
52
 
49
53
  // Extract SID cookie from response
50
54
  const setCookie = response.headers.get("set-cookie")
@@ -52,14 +56,18 @@ export class QBittorrentClient {
52
56
  const match = setCookie.match(/SID=([^;]+)/)
53
57
  if (match) {
54
58
  this.cookie = `SID=${match[1]}`
59
+ debugLog("qBittorrent", "Login successful (cookie)")
55
60
  return true
56
61
  }
57
62
  }
58
63
 
59
64
  // Check response text for "Ok."
60
65
  const text = await response.text()
61
- return text === "Ok."
62
- } catch {
66
+ const success = text === "Ok."
67
+ debugLog("qBittorrent", `Login response: ${text}, success: ${success}`)
68
+ return success
69
+ } catch (e) {
70
+ debugLog("qBittorrent", `Login error: ${e}`)
63
71
  return false
64
72
  }
65
73
  }
@@ -566,6 +566,8 @@ export class AppConfigurator extends BoxRenderable {
566
566
  const existingClients = await client.getDownloadClients()
567
567
  const clientName = type === "qbittorrent" ? "qBittorrent" : "SABnzbd"
568
568
  const alreadyExists = existingClients.some((c) => c.name === clientName)
569
+
570
+ // Skip adding if already exists, but qBittorrent config was already done above
569
571
  if (alreadyExists) continue
570
572
 
571
573
  if (type === "qbittorrent") {
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Full Auto Setup Screen
3
+ * Runs all configuration steps in sequence
4
+ */
5
+
6
+ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
7
+ import { createPageLayout } from "../components/PageLayout"
8
+ import type { EasiarrConfig } from "../../config/schema"
9
+ import { ArrApiClient, type AddRootFolderOptions } from "../../api/arr-api"
10
+ import { ProwlarrClient, type ArrAppType } from "../../api/prowlarr-api"
11
+ import { QBittorrentClient, type QBittorrentCategory } from "../../api/qbittorrent-api"
12
+ import { getApp } from "../../apps/registry"
13
+ // import type { AppId } from "../../config/schema"
14
+ import { getCategoriesForApps } from "../../utils/categories"
15
+ import { readEnvSync } from "../../utils/env"
16
+ import { debugLog } from "../../utils/debug"
17
+
18
+ interface SetupStep {
19
+ name: string
20
+ status: "pending" | "running" | "success" | "error" | "skipped"
21
+ message?: string
22
+ }
23
+
24
+ const ARR_APP_TYPES: Record<string, ArrAppType> = {
25
+ radarr: "Radarr",
26
+ sonarr: "Sonarr",
27
+ lidarr: "Lidarr",
28
+ readarr: "Readarr",
29
+ }
30
+
31
+ export class FullAutoSetup extends BoxRenderable {
32
+ private config: EasiarrConfig
33
+ private cliRenderer: CliRenderer
34
+ private onBack: () => void
35
+ private keyHandler!: (key: KeyEvent) => void
36
+ private contentBox!: BoxRenderable
37
+ private pageContainer!: BoxRenderable
38
+
39
+ private isRunning = false
40
+ private isDone = false
41
+ private steps: SetupStep[] = []
42
+ private globalUsername = ""
43
+ private globalPassword = ""
44
+ private env: Record<string, string> = {}
45
+
46
+ constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
47
+ const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
48
+ title: "Full Auto Setup",
49
+ stepInfo: "Configure all services automatically",
50
+ footerHint: "Enter Start/Continue Esc Back",
51
+ })
52
+ super(cliRenderer, { width: "100%", height: "100%" })
53
+ this.add(pageContainer)
54
+
55
+ this.config = config
56
+ this.cliRenderer = cliRenderer
57
+ this.onBack = onBack
58
+ this.contentBox = contentBox
59
+ this.pageContainer = pageContainer
60
+
61
+ this.env = readEnvSync()
62
+ this.globalUsername = this.env["GLOBAL_USERNAME"] || "admin"
63
+ this.globalPassword = this.env["GLOBAL_PASSWORD"] || ""
64
+
65
+ this.initKeyHandler()
66
+ this.initSteps()
67
+ this.refreshContent()
68
+ }
69
+
70
+ private initSteps(): void {
71
+ this.steps = [
72
+ { name: "Root Folders", status: "pending" },
73
+ { name: "Authentication", status: "pending" },
74
+ { name: "Prowlarr Apps", status: "pending" },
75
+ { name: "FlareSolverr", status: "pending" },
76
+ { name: "qBittorrent", status: "pending" },
77
+ ]
78
+ }
79
+
80
+ private initKeyHandler(): void {
81
+ this.keyHandler = (key: KeyEvent) => {
82
+ debugLog("FullAutoSetup", `Key: ${key.name}, running=${this.isRunning}`)
83
+
84
+ if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
85
+ if (!this.isRunning) {
86
+ this.cleanup()
87
+ }
88
+ return
89
+ }
90
+
91
+ if (key.name === "return") {
92
+ if (this.isDone) {
93
+ this.cleanup()
94
+ } else if (!this.isRunning) {
95
+ this.runSetup()
96
+ }
97
+ }
98
+ }
99
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
100
+ debugLog("FullAutoSetup", "Key handler registered")
101
+ }
102
+
103
+ private async runSetup(): Promise<void> {
104
+ this.isRunning = true
105
+ this.refreshContent()
106
+
107
+ // Step 1: Root folders
108
+ await this.setupRootFolders()
109
+
110
+ // Step 2: Authentication
111
+ await this.setupAuthentication()
112
+
113
+ // Step 3: Prowlarr apps
114
+ await this.setupProwlarrApps()
115
+
116
+ // Step 4: FlareSolverr
117
+ await this.setupFlareSolverr()
118
+
119
+ // Step 5: qBittorrent
120
+ await this.setupQBittorrent()
121
+
122
+ this.isRunning = false
123
+ this.isDone = true
124
+ this.refreshContent()
125
+ }
126
+
127
+ private async setupRootFolders(): Promise<void> {
128
+ this.updateStep("Root Folders", "running")
129
+ this.refreshContent()
130
+
131
+ try {
132
+ const arrApps = this.config.apps.filter((a) => {
133
+ const def = getApp(a.id)
134
+ return a.enabled && def?.rootFolder
135
+ })
136
+
137
+ for (const app of arrApps) {
138
+ const def = getApp(app.id)
139
+ if (!def?.rootFolder) continue
140
+
141
+ const apiKey = this.env[`API_KEY_${app.id.toUpperCase()}`]
142
+ if (!apiKey) continue
143
+
144
+ const port = app.port || def.defaultPort
145
+ const client = new ArrApiClient("localhost", port, apiKey, def.rootFolder.apiVersion)
146
+
147
+ try {
148
+ const existing = await client.getRootFolders()
149
+ if (existing.length === 0) {
150
+ const options: AddRootFolderOptions = { path: def.rootFolder.path }
151
+ if (app.id === "lidarr") options.name = "Music"
152
+ await client.addRootFolder(options)
153
+ }
154
+ } catch {
155
+ // Skip individual failures
156
+ }
157
+ }
158
+
159
+ this.updateStep("Root Folders", "success")
160
+ } catch (e) {
161
+ this.updateStep("Root Folders", "error", `${e}`)
162
+ }
163
+ this.refreshContent()
164
+ }
165
+
166
+ private async setupAuthentication(): Promise<void> {
167
+ this.updateStep("Authentication", "running")
168
+ this.refreshContent()
169
+
170
+ if (!this.globalPassword) {
171
+ this.updateStep("Authentication", "skipped", "No GLOBAL_PASSWORD set")
172
+ this.refreshContent()
173
+ return
174
+ }
175
+
176
+ try {
177
+ const arrApps = this.config.apps.filter((a) => {
178
+ const def = getApp(a.id)
179
+ return a.enabled && (def?.rootFolder || a.id === "prowlarr")
180
+ })
181
+
182
+ for (const app of arrApps) {
183
+ const def = getApp(app.id)
184
+ if (!def) continue
185
+
186
+ const apiKey = this.env[`API_KEY_${app.id.toUpperCase()}`]
187
+ if (!apiKey) continue
188
+
189
+ const port = app.port || def.defaultPort
190
+ const apiVersion = app.id === "prowlarr" ? "v1" : def.rootFolder?.apiVersion || "v3"
191
+ const client = new ArrApiClient("localhost", port, apiKey, apiVersion)
192
+
193
+ try {
194
+ await client.updateHostConfig(this.globalUsername, this.globalPassword, false)
195
+ } catch {
196
+ // Skip individual failures
197
+ }
198
+ }
199
+
200
+ this.updateStep("Authentication", "success")
201
+ } catch (e) {
202
+ this.updateStep("Authentication", "error", `${e}`)
203
+ }
204
+ this.refreshContent()
205
+ }
206
+
207
+ private async setupProwlarrApps(): Promise<void> {
208
+ this.updateStep("Prowlarr Apps", "running")
209
+ this.refreshContent()
210
+
211
+ const apiKey = this.env["API_KEY_PROWLARR"]
212
+ if (!apiKey) {
213
+ this.updateStep("Prowlarr Apps", "skipped", "No Prowlarr API key")
214
+ this.refreshContent()
215
+ return
216
+ }
217
+
218
+ try {
219
+ const prowlarrConfig = this.config.apps.find((a) => a.id === "prowlarr")
220
+ const prowlarrPort = prowlarrConfig?.port || 9696
221
+ const prowlarr = new ProwlarrClient("localhost", prowlarrPort, apiKey)
222
+
223
+ const arrApps = this.config.apps.filter((a) => {
224
+ return a.enabled && ARR_APP_TYPES[a.id]
225
+ })
226
+
227
+ for (const app of arrApps) {
228
+ const appType = ARR_APP_TYPES[app.id]
229
+ if (!appType) continue
230
+
231
+ const appApiKey = this.env[`API_KEY_${app.id.toUpperCase()}`]
232
+ if (!appApiKey) continue
233
+
234
+ const def = getApp(app.id)
235
+ const port = app.port || def?.defaultPort || 7878
236
+
237
+ try {
238
+ await prowlarr.addArrApp(appType, app.id, port, appApiKey, "prowlarr", prowlarrPort)
239
+ } catch {
240
+ // Skip - may already exist
241
+ }
242
+ }
243
+
244
+ // Trigger sync
245
+ try {
246
+ await prowlarr.syncApplications()
247
+ } catch {
248
+ // May fail if no indexers
249
+ }
250
+
251
+ this.updateStep("Prowlarr Apps", "success")
252
+ } catch (e) {
253
+ this.updateStep("Prowlarr Apps", "error", `${e}`)
254
+ }
255
+ this.refreshContent()
256
+ }
257
+
258
+ private async setupFlareSolverr(): Promise<void> {
259
+ this.updateStep("FlareSolverr", "running")
260
+ this.refreshContent()
261
+
262
+ const apiKey = this.env["API_KEY_PROWLARR"]
263
+ const flaresolverr = this.config.apps.find((a) => a.id === "flaresolverr" && a.enabled)
264
+
265
+ if (!apiKey || !flaresolverr) {
266
+ this.updateStep("FlareSolverr", "skipped", "Not enabled or no Prowlarr")
267
+ this.refreshContent()
268
+ return
269
+ }
270
+
271
+ try {
272
+ const prowlarrConfig = this.config.apps.find((a) => a.id === "prowlarr")
273
+ const prowlarrPort = prowlarrConfig?.port || 9696
274
+ const prowlarr = new ProwlarrClient("localhost", prowlarrPort, apiKey)
275
+
276
+ await prowlarr.configureFlareSolverr("http://flaresolverr:8191")
277
+ this.updateStep("FlareSolverr", "success")
278
+ } catch (e) {
279
+ this.updateStep("FlareSolverr", "error", `${e}`)
280
+ }
281
+ this.refreshContent()
282
+ }
283
+
284
+ private async setupQBittorrent(): Promise<void> {
285
+ this.updateStep("qBittorrent", "running")
286
+ this.refreshContent()
287
+
288
+ const qbConfig = this.config.apps.find((a) => a.id === "qbittorrent" && a.enabled)
289
+ if (!qbConfig) {
290
+ this.updateStep("qBittorrent", "skipped", "Not enabled")
291
+ this.refreshContent()
292
+ return
293
+ }
294
+
295
+ try {
296
+ const host = "localhost"
297
+ const port = qbConfig.port || 8080
298
+ const user = this.env["QBITTORRENT_USER"] || "admin"
299
+ const pass = this.env["QBITTORRENT_PASS"] || ""
300
+
301
+ if (!pass) {
302
+ this.updateStep("qBittorrent", "skipped", "No QBITTORRENT_PASS in .env")
303
+ this.refreshContent()
304
+ return
305
+ }
306
+
307
+ const client = new QBittorrentClient(host, port, user, pass)
308
+ const loggedIn = await client.login()
309
+
310
+ if (!loggedIn) {
311
+ this.updateStep("qBittorrent", "error", "Login failed")
312
+ this.refreshContent()
313
+ return
314
+ }
315
+
316
+ const enabledApps = this.config.apps.filter((a) => a.enabled).map((a) => a.id)
317
+ const categories: QBittorrentCategory[] = getCategoriesForApps(enabledApps).map((cat) => ({
318
+ name: cat.name,
319
+ savePath: `/data/torrents/${cat.name}`,
320
+ }))
321
+
322
+ await client.configureTRaSHCompliant(categories)
323
+ this.updateStep("qBittorrent", "success")
324
+ } catch (e) {
325
+ this.updateStep("qBittorrent", "error", `${e}`)
326
+ }
327
+ this.refreshContent()
328
+ }
329
+
330
+ private updateStep(name: string, status: SetupStep["status"], message?: string): void {
331
+ const step = this.steps.find((s) => s.name === name)
332
+ if (step) {
333
+ step.status = status
334
+ step.message = message
335
+ }
336
+ }
337
+
338
+ private refreshContent(): void {
339
+ this.contentBox.getChildren().forEach((child) => child.destroy())
340
+
341
+ if (!this.isRunning && !this.isDone) {
342
+ // Show intro
343
+ this.contentBox.add(
344
+ new TextRenderable(this.cliRenderer, {
345
+ content: "This will automatically configure:\n\n",
346
+ fg: "#8be9fd",
347
+ })
348
+ )
349
+ this.steps.forEach((step) => {
350
+ this.contentBox.add(
351
+ new TextRenderable(this.cliRenderer, {
352
+ content: ` • ${step.name}\n`,
353
+ fg: "#aaaaaa",
354
+ })
355
+ )
356
+ })
357
+ this.contentBox.add(
358
+ new TextRenderable(this.cliRenderer, {
359
+ content: "\n\nPress Enter to start, Esc to go back.\n",
360
+ fg: "#50fa7b",
361
+ })
362
+ )
363
+ } else {
364
+ // Show progress
365
+ this.contentBox.add(
366
+ new TextRenderable(this.cliRenderer, {
367
+ content: this.isDone ? "Setup Complete!\n\n" : "Setting up...\n\n",
368
+ fg: this.isDone ? "#50fa7b" : "#f1fa8c",
369
+ })
370
+ )
371
+
372
+ this.steps.forEach((step) => {
373
+ let icon = "⏳"
374
+ let color = "#aaaaaa"
375
+ if (step.status === "success") {
376
+ icon = "✅"
377
+ color = "#50fa7b"
378
+ } else if (step.status === "error") {
379
+ icon = "❌"
380
+ color = "#ff5555"
381
+ } else if (step.status === "skipped") {
382
+ icon = "⏭️"
383
+ color = "#6272a4"
384
+ } else if (step.status === "running") {
385
+ icon = "🔄"
386
+ color = "#f1fa8c"
387
+ }
388
+
389
+ this.contentBox.add(
390
+ new TextRenderable(this.cliRenderer, {
391
+ content: `${icon} ${step.name}`,
392
+ fg: color,
393
+ })
394
+ )
395
+ if (step.message) {
396
+ this.contentBox.add(
397
+ new TextRenderable(this.cliRenderer, {
398
+ content: ` - ${step.message}`,
399
+ fg: "#6272a4",
400
+ })
401
+ )
402
+ }
403
+ this.contentBox.add(new TextRenderable(this.cliRenderer, { content: "\n", fg: "#aaaaaa" }))
404
+ })
405
+
406
+ if (this.isDone) {
407
+ this.contentBox.add(
408
+ new TextRenderable(this.cliRenderer, {
409
+ content: "\nPress Enter to go back.\n",
410
+ fg: "#50fa7b",
411
+ })
412
+ )
413
+ }
414
+ }
415
+ }
416
+
417
+ private cleanup(): void {
418
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
419
+ debugLog("FullAutoSetup", "Key handler removed")
420
+ this.destroy()
421
+ this.onBack()
422
+ }
423
+ }
@@ -13,6 +13,8 @@ import { ApiKeyViewer } from "./ApiKeyViewer"
13
13
  import { AppConfigurator } from "./AppConfigurator"
14
14
  import { TRaSHProfileSetup } from "./TRaSHProfileSetup"
15
15
  import { ProwlarrSetup } from "./ProwlarrSetup"
16
+ import { QBittorrentSetup } from "./QBittorrentSetup"
17
+ import { FullAutoSetup } from "./FullAutoSetup"
16
18
 
17
19
  export class MainMenu {
18
20
  private renderer: RenderContext
@@ -114,6 +116,14 @@ export class MainMenu {
114
116
  name: "🔗 Prowlarr Setup",
115
117
  description: "Sync indexers to *arr apps, FlareSolverr",
116
118
  },
119
+ {
120
+ name: "⚡ qBittorrent Setup",
121
+ description: "Configure TRaSH-compliant paths and categories",
122
+ },
123
+ {
124
+ name: "🚀 Full Auto Setup",
125
+ description: "Run all configurations (Auth, Root Folders, Prowlarr, etc.)",
126
+ },
117
127
  { name: "❌ Exit", description: "Close easiarr" },
118
128
  ],
119
129
  })
@@ -179,7 +189,29 @@ export class MainMenu {
179
189
  this.container.add(prowlarrSetup)
180
190
  break
181
191
  }
182
- case 8:
192
+ case 8: {
193
+ // qBittorrent Setup
194
+ this.menu.blur()
195
+ this.page.visible = false
196
+ const qbSetup = new QBittorrentSetup(this.renderer as CliRenderer, this.config, () => {
197
+ this.page.visible = true
198
+ this.menu.focus()
199
+ })
200
+ this.container.add(qbSetup)
201
+ break
202
+ }
203
+ case 9: {
204
+ // Full Auto Setup
205
+ this.menu.blur()
206
+ this.page.visible = false
207
+ const autoSetup = new FullAutoSetup(this.renderer as CliRenderer, this.config, () => {
208
+ this.page.visible = true
209
+ this.menu.focus()
210
+ })
211
+ this.container.add(autoSetup)
212
+ break
213
+ }
214
+ case 10:
183
215
  process.exit(0)
184
216
  break
185
217
  }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * qBittorrent Setup Screen
3
+ * Configure qBittorrent for TRaSH Guide compliance
4
+ */
5
+
6
+ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
7
+ import { createPageLayout } from "../components/PageLayout"
8
+ import type { EasiarrConfig } from "../../config/schema"
9
+ import { QBittorrentClient, type QBittorrentCategory } from "../../api/qbittorrent-api"
10
+ import { getCategoriesForApps } from "../../utils/categories"
11
+ import { debugLog } from "../../utils/debug"
12
+
13
+ type Step = "menu" | "host" | "port" | "user" | "pass" | "configuring" | "done"
14
+
15
+ export class QBittorrentSetup extends BoxRenderable {
16
+ private config: EasiarrConfig
17
+ private cliRenderer: CliRenderer
18
+ private onBack: () => void
19
+ private keyHandler!: (key: KeyEvent) => void
20
+ private contentBox!: BoxRenderable
21
+ private pageContainer!: BoxRenderable
22
+
23
+ private step: Step = "menu"
24
+ private menuIndex = 0
25
+ private host = "localhost"
26
+ private port = 8080
27
+ private user = "admin"
28
+ private pass = ""
29
+ private inputValue = ""
30
+ private statusMessage = ""
31
+ private statusColor = "#f1fa8c" // yellow
32
+
33
+ constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
34
+ const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
35
+ title: "qBittorrent Setup",
36
+ stepInfo: "Configure TRaSH-compliant paths and categories",
37
+ footerHint: "Enter Submit Esc Back",
38
+ })
39
+ super(cliRenderer, { width: "100%", height: "100%" })
40
+ this.add(pageContainer)
41
+
42
+ this.config = config
43
+ this.cliRenderer = cliRenderer
44
+ this.onBack = onBack
45
+ this.contentBox = contentBox
46
+ this.pageContainer = pageContainer
47
+
48
+ this.initKeyHandler()
49
+ this.refreshContent()
50
+ }
51
+
52
+ private initKeyHandler(): void {
53
+ this.keyHandler = (key: KeyEvent) => {
54
+ debugLog("qBittorrent", `Key: ${key.name}, step=${this.step}`)
55
+
56
+ if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
57
+ if (this.step === "menu") {
58
+ this.cleanup()
59
+ } else {
60
+ this.step = "menu"
61
+ this.refreshContent()
62
+ }
63
+ return
64
+ }
65
+
66
+ if (this.step === "menu") {
67
+ this.handleMenuKeys(key)
68
+ } else if (this.step === "host" || this.step === "port" || this.step === "user" || this.step === "pass") {
69
+ this.handleInputKeys(key)
70
+ } else if (this.step === "done") {
71
+ if (key.name === "return") {
72
+ this.step = "menu"
73
+ this.refreshContent()
74
+ }
75
+ }
76
+ }
77
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
78
+ debugLog("qBittorrent", "Key handler registered")
79
+ }
80
+
81
+ private handleMenuKeys(key: KeyEvent): void {
82
+ const menuItems = ["Configure qBittorrent", "Back"]
83
+
84
+ if (key.name === "up") {
85
+ this.menuIndex = Math.max(0, this.menuIndex - 1)
86
+ this.refreshContent()
87
+ } else if (key.name === "down") {
88
+ this.menuIndex = Math.min(menuItems.length - 1, this.menuIndex + 1)
89
+ this.refreshContent()
90
+ } else if (key.name === "return") {
91
+ if (this.menuIndex === 0) {
92
+ this.step = "host"
93
+ this.inputValue = this.host
94
+ this.refreshContent()
95
+ } else {
96
+ this.cleanup()
97
+ }
98
+ }
99
+ }
100
+
101
+ private handleInputKeys(key: KeyEvent): void {
102
+ if (key.name === "return") {
103
+ this.handleInputSubmit()
104
+ } else if (key.name === "backspace") {
105
+ this.inputValue = this.inputValue.slice(0, -1)
106
+ this.refreshContent()
107
+ } else if (key.sequence && key.sequence.length === 1 && !key.ctrl) {
108
+ this.inputValue += key.sequence
109
+ this.refreshContent()
110
+ }
111
+ }
112
+
113
+ private handleInputSubmit(): void {
114
+ if (this.step === "host") {
115
+ if (this.inputValue.trim()) this.host = this.inputValue.trim()
116
+ this.step = "port"
117
+ this.inputValue = String(this.port)
118
+ } else if (this.step === "port") {
119
+ const p = parseInt(this.inputValue)
120
+ if (!isNaN(p)) this.port = p
121
+ this.step = "user"
122
+ this.inputValue = this.user
123
+ } else if (this.step === "user") {
124
+ if (this.inputValue.trim()) this.user = this.inputValue.trim()
125
+ this.step = "pass"
126
+ this.inputValue = ""
127
+ } else if (this.step === "pass") {
128
+ this.pass = this.inputValue
129
+ this.step = "configuring"
130
+ this.configure()
131
+ return
132
+ }
133
+ this.refreshContent()
134
+ }
135
+
136
+ private async configure(): Promise<void> {
137
+ this.statusMessage = "⏳ Connecting to qBittorrent..."
138
+ this.statusColor = "#f1fa8c"
139
+ this.refreshContent()
140
+
141
+ try {
142
+ debugLog("qBittorrent", `Connecting to ${this.host}:${this.port}`)
143
+ const client = new QBittorrentClient(this.host, this.port, this.user, this.pass)
144
+ const loggedIn = await client.login()
145
+
146
+ if (!loggedIn) {
147
+ this.statusMessage = "❌ Login failed. Check credentials."
148
+ this.statusColor = "#ff5555"
149
+ this.step = "done"
150
+ this.refreshContent()
151
+ return
152
+ }
153
+
154
+ this.statusMessage = "✅ Logged in. Configuring..."
155
+ this.statusColor = "#50fa7b"
156
+ this.refreshContent()
157
+
158
+ // Get categories from enabled *arr apps
159
+ const enabledApps = this.config.apps.filter((a) => a.enabled).map((a) => a.id)
160
+ const categories: QBittorrentCategory[] = getCategoriesForApps(enabledApps).map((cat) => ({
161
+ name: cat.name,
162
+ savePath: `/data/torrents/${cat.name}`,
163
+ }))
164
+
165
+ await client.configureTRaSHCompliant(categories)
166
+
167
+ const catNames = categories.map((c) => c.name).join(", ") || "none"
168
+ this.statusMessage = `✅ Done!\n\n save_path: /data/torrents\n Categories: ${catNames}\n\n Press Enter to continue.`
169
+ this.statusColor = "#50fa7b"
170
+ this.step = "done"
171
+ this.refreshContent()
172
+ } catch (e) {
173
+ debugLog("qBittorrent", `Error: ${e}`)
174
+ this.statusMessage = `❌ Error: ${e}`
175
+ this.statusColor = "#ff5555"
176
+ this.step = "done"
177
+ this.refreshContent()
178
+ }
179
+ }
180
+
181
+ private refreshContent(): void {
182
+ // Clear content box
183
+ this.contentBox.getChildren().forEach((child) => child.destroy())
184
+
185
+ if (this.step === "menu") {
186
+ this.renderMenu()
187
+ } else if (this.step === "host" || this.step === "port" || this.step === "user" || this.step === "pass") {
188
+ this.renderInput()
189
+ } else if (this.step === "configuring" || this.step === "done") {
190
+ this.renderStatus()
191
+ }
192
+ }
193
+
194
+ private renderMenu(): void {
195
+ this.contentBox.add(
196
+ new TextRenderable(this.cliRenderer, {
197
+ content: "Select an action:\n\n",
198
+ fg: "#aaaaaa",
199
+ })
200
+ )
201
+
202
+ const items = [
203
+ { name: "🔧 Configure qBittorrent", desc: "Set save path and categories" },
204
+ { name: "⬅️ Back", desc: "Return to main menu" },
205
+ ]
206
+
207
+ items.forEach((item, idx) => {
208
+ const pointer = idx === this.menuIndex ? "→ " : " "
209
+ const fg = idx === this.menuIndex ? "#50fa7b" : "#8be9fd"
210
+
211
+ this.contentBox.add(
212
+ new TextRenderable(this.cliRenderer, {
213
+ content: `${pointer}${item.name}\n`,
214
+ fg,
215
+ })
216
+ )
217
+ this.contentBox.add(
218
+ new TextRenderable(this.cliRenderer, {
219
+ content: ` ${item.desc}\n\n`,
220
+ fg: "#6272a4",
221
+ })
222
+ )
223
+ })
224
+ }
225
+
226
+ private renderInput(): void {
227
+ const labels: Record<string, string> = {
228
+ host: "Enter qBittorrent host (e.g., localhost or qbittorrent):",
229
+ port: "Enter qBittorrent WebUI port:",
230
+ user: "Enter qBittorrent username:",
231
+ pass: "Enter qBittorrent password:",
232
+ }
233
+
234
+ this.contentBox.add(
235
+ new TextRenderable(this.cliRenderer, {
236
+ content: `${labels[this.step]}\n\n`,
237
+ fg: "#8be9fd",
238
+ })
239
+ )
240
+
241
+ const displayValue = this.step === "pass" ? "*".repeat(this.inputValue.length) : this.inputValue
242
+ this.contentBox.add(
243
+ new TextRenderable(this.cliRenderer, {
244
+ content: `> ${displayValue}_`,
245
+ fg: "#ffffff",
246
+ })
247
+ )
248
+ }
249
+
250
+ private renderStatus(): void {
251
+ this.contentBox.add(
252
+ new TextRenderable(this.cliRenderer, {
253
+ content: this.statusMessage,
254
+ fg: this.statusColor,
255
+ })
256
+ )
257
+ }
258
+
259
+ private cleanup(): void {
260
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
261
+ debugLog("qBittorrent", "Key handler removed")
262
+ this.destroy()
263
+ this.onBack()
264
+ }
265
+ }