@muhammedaksam/easiarr 0.3.4 → 0.4.1
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
|
+
"version": "0.4.1",
|
|
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)
|
|
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
|
-
|
|
62
|
-
|
|
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
|
}
|
package/src/apps/registry.ts
CHANGED
|
@@ -247,6 +247,20 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
247
247
|
// TRaSH: Mount full /data for consistent paths with *arr apps (enables hardlinks)
|
|
248
248
|
volumes: (root) => [`${root}/config/qbittorrent:/config`, `${root}/data:/data`],
|
|
249
249
|
environment: { WEBUI_PORT: "8080" },
|
|
250
|
+
secrets: [
|
|
251
|
+
{
|
|
252
|
+
name: "QBITTORRENT_USER",
|
|
253
|
+
description: "Username for qBittorrent WebUI",
|
|
254
|
+
required: false,
|
|
255
|
+
default: "admin",
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: "QBITTORRENT_PASSWORD",
|
|
259
|
+
description: "Password for qBittorrent WebUI",
|
|
260
|
+
required: false,
|
|
261
|
+
mask: true,
|
|
262
|
+
},
|
|
263
|
+
],
|
|
250
264
|
trashGuide: "docs/Downloaders/qBittorrent/",
|
|
251
265
|
},
|
|
252
266
|
|
|
@@ -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_PASSWORD"] || this.env["QBITTORRENT_PASS"] || ""
|
|
300
|
+
|
|
301
|
+
if (!pass) {
|
|
302
|
+
this.updateStep("qBittorrent", "skipped", "No QBITTORRENT_PASSWORD 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
|
+
}
|