@muhammedaksam/easiarr 0.3.3 → 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.3",
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",
@@ -188,13 +188,13 @@ export class ProwlarrClient {
188
188
  await this.request(`/indexerproxy/${id}`, { method: "DELETE" })
189
189
  }
190
190
 
191
- // Sync Profile management
191
+ // Sync Profile management (aka App Sync Profile)
192
192
  async getSyncProfiles(): Promise<SyncProfile[]> {
193
- return this.request<SyncProfile[]>("/syncprofile")
193
+ return this.request<SyncProfile[]>("/appsyncprofile")
194
194
  }
195
195
 
196
196
  async createSyncProfile(profile: Omit<SyncProfile, "id">): Promise<SyncProfile> {
197
- return this.request<SyncProfile>("/syncprofile", {
197
+ return this.request<SyncProfile>("/appsyncprofile", {
198
198
  method: "POST",
199
199
  body: JSON.stringify(profile),
200
200
  })
@@ -296,7 +296,10 @@ export class ProwlarrClient {
296
296
 
297
297
  // Sync all apps - triggers Prowlarr to push indexers to connected apps
298
298
  async syncApplications(): Promise<void> {
299
- await this.request("/applications/action/sync", { method: "POST" })
299
+ await this.request("/applications/action/sync", {
300
+ method: "POST",
301
+ body: JSON.stringify({}), // API requires non-empty body
302
+ })
300
303
  }
301
304
 
302
305
  // Add *arr app with auto-detection
@@ -4,6 +4,8 @@
4
4
  * API docs: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)
5
5
  */
6
6
 
7
+ import { debugLog } from "../utils/debug"
8
+
7
9
  export interface QBittorrentPreferences {
8
10
  save_path?: string
9
11
  temp_path_enabled?: boolean
@@ -36,13 +38,17 @@ export class QBittorrentClient {
36
38
  */
37
39
  async login(): Promise<boolean> {
38
40
  try {
41
+ debugLog("qBittorrent", `Logging in to ${this.baseUrl} as ${this.username}`)
39
42
  const response = await fetch(`${this.baseUrl}/api/v2/auth/login`, {
40
43
  method: "POST",
41
44
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
42
45
  body: `username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
43
46
  })
44
47
 
45
- if (!response.ok) return false
48
+ if (!response.ok) {
49
+ debugLog("qBittorrent", `Login failed: ${response.status}`)
50
+ return false
51
+ }
46
52
 
47
53
  // Extract SID cookie from response
48
54
  const setCookie = response.headers.get("set-cookie")
@@ -50,14 +56,18 @@ export class QBittorrentClient {
50
56
  const match = setCookie.match(/SID=([^;]+)/)
51
57
  if (match) {
52
58
  this.cookie = `SID=${match[1]}`
59
+ debugLog("qBittorrent", "Login successful (cookie)")
53
60
  return true
54
61
  }
55
62
  }
56
63
 
57
64
  // Check response text for "Ok."
58
65
  const text = await response.text()
59
- return text === "Ok."
60
- } 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}`)
61
71
  return false
62
72
  }
63
73
  }
@@ -172,7 +182,10 @@ export class QBittorrentClient {
172
182
  * @param categories - Array of {name, savePath} for each enabled *arr app
173
183
  */
174
184
  async configureTRaSHCompliant(categories: QBittorrentCategory[] = []): Promise<void> {
185
+ debugLog("qBittorrent", "Configuring TRaSH-compliant settings")
186
+
175
187
  // 1. Set global preferences
188
+ debugLog("qBittorrent", "Setting save_path to /data/torrents")
176
189
  await this.setPreferences({
177
190
  save_path: "/data/torrents",
178
191
  temp_path_enabled: false,
@@ -183,6 +196,7 @@ export class QBittorrentClient {
183
196
 
184
197
  // 2. Create categories for each enabled media type
185
198
  for (const cat of categories) {
199
+ debugLog("qBittorrent", `Creating category: ${cat.name} -> ${cat.savePath}`)
186
200
  try {
187
201
  await this.createCategory(cat.name, cat.savePath)
188
202
  } catch {
@@ -194,5 +208,6 @@ export class QBittorrentClient {
194
208
  }
195
209
  }
196
210
  }
211
+ debugLog("qBittorrent", "TRaSH configuration complete")
197
212
  }
198
213
  }
@@ -252,7 +252,8 @@ export class AppConfigurator extends BoxRenderable {
252
252
 
253
253
  const appDef = getApp(appId as AppId)
254
254
  const port = appConfig.port || appDef?.defaultPort || 9696
255
- const client = new ArrApiClient("localhost", port, apiKey)
255
+ // Prowlarr uses v1 API, not v3
256
+ const client = new ArrApiClient("localhost", port, apiKey, "v1")
256
257
 
257
258
  try {
258
259
  await client.updateHostConfig(this.globalUsername, this.globalPassword, this.overrideExisting)
@@ -565,6 +566,8 @@ export class AppConfigurator extends BoxRenderable {
565
566
  const existingClients = await client.getDownloadClients()
566
567
  const clientName = type === "qbittorrent" ? "qBittorrent" : "SABnzbd"
567
568
  const alreadyExists = existingClients.some((c) => c.name === clientName)
569
+
570
+ // Skip adding if already exists, but qBittorrent config was already done above
568
571
  if (alreadyExists) continue
569
572
 
570
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
+ }