@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
|
+
"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",
|
package/src/api/prowlarr-api.ts
CHANGED
|
@@ -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[]>("/
|
|
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>("/
|
|
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", {
|
|
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)
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
+
}
|