@muhammedaksam/easiarr 0.1.10 → 0.3.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 +1 -1
- package/src/api/arr-api.ts +51 -36
- package/src/api/custom-format-api.ts +212 -0
- package/src/api/prowlarr-api.ts +301 -0
- package/src/api/qbittorrent-api.ts +198 -0
- package/src/api/quality-profile-api.ts +205 -0
- package/src/apps/registry.ts +6 -6
- package/src/compose/generator.ts +10 -28
- package/src/data/trash-profiles.ts +252 -0
- package/src/ui/screens/AdvancedSettings.ts +2 -1
- package/src/ui/screens/ApiKeyViewer.ts +5 -23
- package/src/ui/screens/AppConfigurator.ts +53 -93
- package/src/ui/screens/MainMenu.ts +33 -1
- package/src/ui/screens/ProwlarrSetup.ts +376 -0
- package/src/ui/screens/SecretsEditor.ts +9 -50
- package/src/ui/screens/TRaSHProfileSetup.ts +371 -0
- package/src/utils/categories.ts +59 -0
- package/src/utils/debug.ts +27 -0
- package/src/utils/env.ts +98 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prowlarr Setup Screen
|
|
3
|
+
* Configures Prowlarr integration with *arr apps, FlareSolverr, and proxies
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
|
|
7
|
+
import { createPageLayout } from "../components/PageLayout"
|
|
8
|
+
import { EasiarrConfig } from "../../config/schema"
|
|
9
|
+
import { getApp } from "../../apps/registry"
|
|
10
|
+
import { ProwlarrClient, ArrAppType } from "../../api/prowlarr-api"
|
|
11
|
+
import { readEnvSync } from "../../utils/env"
|
|
12
|
+
import { debugLog } from "../../utils/debug"
|
|
13
|
+
|
|
14
|
+
interface SetupResult {
|
|
15
|
+
name: string
|
|
16
|
+
status: "pending" | "configuring" | "success" | "error" | "skipped"
|
|
17
|
+
message?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Step = "menu" | "sync-apps" | "flaresolverr" | "sync-profiles" | "done"
|
|
21
|
+
|
|
22
|
+
const ARR_APP_TYPES: Record<string, ArrAppType> = {
|
|
23
|
+
radarr: "Radarr",
|
|
24
|
+
sonarr: "Sonarr",
|
|
25
|
+
lidarr: "Lidarr",
|
|
26
|
+
readarr: "Readarr",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ProwlarrSetup extends BoxRenderable {
|
|
30
|
+
private config: EasiarrConfig
|
|
31
|
+
private cliRenderer: CliRenderer
|
|
32
|
+
private onBack: () => void
|
|
33
|
+
private keyHandler!: (key: KeyEvent) => void
|
|
34
|
+
private results: SetupResult[] = []
|
|
35
|
+
private currentStep: Step = "menu"
|
|
36
|
+
private contentBox!: BoxRenderable
|
|
37
|
+
private pageContainer!: BoxRenderable
|
|
38
|
+
private menuIndex = 0
|
|
39
|
+
private prowlarrClient: ProwlarrClient | null = null
|
|
40
|
+
|
|
41
|
+
constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
|
|
42
|
+
const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
|
|
43
|
+
title: "Prowlarr Setup",
|
|
44
|
+
stepInfo: "Configure indexer sync and proxies",
|
|
45
|
+
footerHint: "↑↓ Navigate Enter Select Esc Back",
|
|
46
|
+
})
|
|
47
|
+
super(cliRenderer, { width: "100%", height: "100%" })
|
|
48
|
+
this.add(pageContainer)
|
|
49
|
+
|
|
50
|
+
this.config = config
|
|
51
|
+
this.cliRenderer = cliRenderer
|
|
52
|
+
this.onBack = onBack
|
|
53
|
+
this.contentBox = contentBox
|
|
54
|
+
this.pageContainer = pageContainer
|
|
55
|
+
|
|
56
|
+
this.initProwlarrClient()
|
|
57
|
+
this.initKeyHandler()
|
|
58
|
+
this.refreshContent()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private initProwlarrClient(): void {
|
|
62
|
+
const env = readEnvSync()
|
|
63
|
+
const apiKey = env["API_KEY_PROWLARR"]
|
|
64
|
+
if (apiKey) {
|
|
65
|
+
const prowlarrConfig = this.config.apps.find((a) => a.id === "prowlarr")
|
|
66
|
+
const port = prowlarrConfig?.port || 9696
|
|
67
|
+
this.prowlarrClient = new ProwlarrClient("localhost", port, apiKey)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private initKeyHandler(): void {
|
|
72
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
73
|
+
debugLog("Prowlarr", `Key: ${key.name}, step=${this.currentStep}`)
|
|
74
|
+
|
|
75
|
+
if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
|
|
76
|
+
if (this.currentStep === "menu") {
|
|
77
|
+
this.cleanup()
|
|
78
|
+
} else {
|
|
79
|
+
this.currentStep = "menu"
|
|
80
|
+
this.refreshContent()
|
|
81
|
+
}
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (this.currentStep === "menu") {
|
|
86
|
+
this.handleMenuKeys(key)
|
|
87
|
+
} else if (this.currentStep === "done") {
|
|
88
|
+
if (key.name === "return" || key.name === "escape") {
|
|
89
|
+
this.currentStep = "menu"
|
|
90
|
+
this.refreshContent()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
95
|
+
debugLog("Prowlarr", "Key handler registered")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private handleMenuKeys(key: KeyEvent): void {
|
|
99
|
+
const menuItems = this.getMenuItems()
|
|
100
|
+
|
|
101
|
+
if (key.name === "up" && this.menuIndex > 0) {
|
|
102
|
+
this.menuIndex--
|
|
103
|
+
this.refreshContent()
|
|
104
|
+
} else if (key.name === "down" && this.menuIndex < menuItems.length - 1) {
|
|
105
|
+
this.menuIndex++
|
|
106
|
+
this.refreshContent()
|
|
107
|
+
} else if (key.name === "return") {
|
|
108
|
+
this.executeMenuItem(this.menuIndex)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private getMenuItems(): { name: string; description: string; action: () => void }[] {
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
name: "🔗 Sync *arr Apps",
|
|
116
|
+
description: "Connect Radarr/Sonarr/etc to Prowlarr",
|
|
117
|
+
action: () => this.syncArrApps(),
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "🛡️ Setup FlareSolverr",
|
|
121
|
+
description: "Add Cloudflare bypass proxy",
|
|
122
|
+
action: () => this.setupFlareSolverr(),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "📊 Create Sync Profiles",
|
|
126
|
+
description: "Limited API indexer profiles",
|
|
127
|
+
action: () => this.createSyncProfiles(),
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "↩️ Back",
|
|
131
|
+
description: "Return to main menu",
|
|
132
|
+
action: () => this.cleanup(),
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private executeMenuItem(index: number): void {
|
|
138
|
+
const items = this.getMenuItems()
|
|
139
|
+
if (index >= 0 && index < items.length) {
|
|
140
|
+
items[index].action()
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async syncArrApps(): Promise<void> {
|
|
145
|
+
if (!this.prowlarrClient) {
|
|
146
|
+
this.results = [{ name: "Prowlarr", status: "error", message: "API key not found" }]
|
|
147
|
+
this.currentStep = "done"
|
|
148
|
+
this.refreshContent()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.currentStep = "sync-apps"
|
|
153
|
+
this.results = []
|
|
154
|
+
|
|
155
|
+
const arrApps = this.config.apps.filter((a) => a.enabled && Object.keys(ARR_APP_TYPES).includes(a.id))
|
|
156
|
+
const env = readEnvSync()
|
|
157
|
+
const prowlarrConfig = this.config.apps.find((a) => a.id === "prowlarr")
|
|
158
|
+
const prowlarrPort = prowlarrConfig?.port || 9696
|
|
159
|
+
|
|
160
|
+
for (const app of arrApps) {
|
|
161
|
+
const appType = ARR_APP_TYPES[app.id]
|
|
162
|
+
if (!appType) continue
|
|
163
|
+
|
|
164
|
+
this.results.push({ name: app.id, status: "configuring" })
|
|
165
|
+
this.refreshContent()
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const apiKey = env[`API_KEY_${app.id.toUpperCase()}`]
|
|
169
|
+
if (!apiKey) {
|
|
170
|
+
const result = this.results.find((r) => r.name === app.id)
|
|
171
|
+
if (result) {
|
|
172
|
+
result.status = "skipped"
|
|
173
|
+
result.message = "No API key"
|
|
174
|
+
}
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const appDef = getApp(app.id)
|
|
179
|
+
const port = app.port || appDef?.defaultPort || 7878
|
|
180
|
+
|
|
181
|
+
await this.prowlarrClient.addArrApp(appType, "localhost", port, apiKey, "localhost", prowlarrPort)
|
|
182
|
+
|
|
183
|
+
const result = this.results.find((r) => r.name === app.id)
|
|
184
|
+
if (result) {
|
|
185
|
+
result.status = "success"
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const result = this.results.find((r) => r.name === app.id)
|
|
189
|
+
if (result) {
|
|
190
|
+
result.status = "error"
|
|
191
|
+
result.message = error instanceof Error ? error.message : "Unknown error"
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
this.refreshContent()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Trigger sync
|
|
198
|
+
try {
|
|
199
|
+
await this.prowlarrClient.syncApplications()
|
|
200
|
+
} catch {
|
|
201
|
+
// Sync might fail if no apps, that's ok
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.currentStep = "done"
|
|
205
|
+
this.refreshContent()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async setupFlareSolverr(): Promise<void> {
|
|
209
|
+
if (!this.prowlarrClient) {
|
|
210
|
+
this.results = [{ name: "Prowlarr", status: "error", message: "API key not found" }]
|
|
211
|
+
this.currentStep = "done"
|
|
212
|
+
this.refreshContent()
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.currentStep = "flaresolverr"
|
|
217
|
+
this.results = [{ name: "FlareSolverr", status: "configuring" }]
|
|
218
|
+
this.refreshContent()
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
// Check if FlareSolverr is enabled
|
|
222
|
+
const fsConfig = this.config.apps.find((a) => a.id === "flaresolverr")
|
|
223
|
+
if (!fsConfig?.enabled) {
|
|
224
|
+
this.results[0].status = "skipped"
|
|
225
|
+
this.results[0].message = "FlareSolverr not enabled in config"
|
|
226
|
+
} else {
|
|
227
|
+
const fsPort = fsConfig.port || 8191
|
|
228
|
+
await this.prowlarrClient.configureFlareSolverr(`http://flaresolverr:${fsPort}`)
|
|
229
|
+
this.results[0].status = "success"
|
|
230
|
+
this.results[0].message = "Proxy added with 'flaresolverr' tag"
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
this.results[0].status = "error"
|
|
234
|
+
this.results[0].message = error instanceof Error ? error.message : "Unknown error"
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.currentStep = "done"
|
|
238
|
+
this.refreshContent()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async createSyncProfiles(): Promise<void> {
|
|
242
|
+
if (!this.prowlarrClient) {
|
|
243
|
+
this.results = [{ name: "Prowlarr", status: "error", message: "API key not found" }]
|
|
244
|
+
this.currentStep = "done"
|
|
245
|
+
this.refreshContent()
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.currentStep = "sync-profiles"
|
|
250
|
+
this.results = [
|
|
251
|
+
{ name: "Automatic Search", status: "configuring" },
|
|
252
|
+
{ name: "Interactive Search", status: "configuring" },
|
|
253
|
+
]
|
|
254
|
+
this.refreshContent()
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
await this.prowlarrClient.createLimitedAPISyncProfiles()
|
|
258
|
+
this.results[0].status = "success"
|
|
259
|
+
this.results[0].message = "RSS disabled, auto+interactive enabled"
|
|
260
|
+
this.results[1].status = "success"
|
|
261
|
+
this.results[1].message = "RSS+auto disabled, interactive only"
|
|
262
|
+
} catch (error) {
|
|
263
|
+
this.results.forEach((r) => {
|
|
264
|
+
r.status = "error"
|
|
265
|
+
r.message = error instanceof Error ? error.message : "Unknown error"
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.currentStep = "done"
|
|
270
|
+
this.refreshContent()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private refreshContent(): void {
|
|
274
|
+
this.contentBox.getChildren().forEach((child) => child.destroy())
|
|
275
|
+
|
|
276
|
+
if (this.currentStep === "menu") {
|
|
277
|
+
this.renderMenu()
|
|
278
|
+
} else {
|
|
279
|
+
this.renderResults()
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private renderMenu(): void {
|
|
284
|
+
if (!this.prowlarrClient) {
|
|
285
|
+
this.contentBox.add(
|
|
286
|
+
new TextRenderable(this.cliRenderer, {
|
|
287
|
+
content: "⚠️ Prowlarr API key not found!\nRun 'Extract API Keys' first.\n\n",
|
|
288
|
+
fg: "#ff5555",
|
|
289
|
+
})
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.contentBox.add(
|
|
294
|
+
new TextRenderable(this.cliRenderer, {
|
|
295
|
+
content: "Select an action:\n\n",
|
|
296
|
+
fg: "#aaaaaa",
|
|
297
|
+
})
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
this.getMenuItems().forEach((item, idx) => {
|
|
301
|
+
const pointer = idx === this.menuIndex ? "→ " : " "
|
|
302
|
+
const fg = idx === this.menuIndex ? "#50fa7b" : "#8be9fd"
|
|
303
|
+
|
|
304
|
+
this.contentBox.add(
|
|
305
|
+
new TextRenderable(this.cliRenderer, {
|
|
306
|
+
content: `${pointer}${item.name}\n`,
|
|
307
|
+
fg,
|
|
308
|
+
})
|
|
309
|
+
)
|
|
310
|
+
this.contentBox.add(
|
|
311
|
+
new TextRenderable(this.cliRenderer, {
|
|
312
|
+
content: ` ${item.description}\n\n`,
|
|
313
|
+
fg: "#6272a4",
|
|
314
|
+
})
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private renderResults(): void {
|
|
320
|
+
const headerText = this.currentStep === "done" ? "Results:\n\n" : "Configuring...\n\n"
|
|
321
|
+
this.contentBox.add(
|
|
322
|
+
new TextRenderable(this.cliRenderer, {
|
|
323
|
+
content: headerText,
|
|
324
|
+
fg: this.currentStep === "done" ? "#50fa7b" : "#f1fa8c",
|
|
325
|
+
})
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
for (const result of this.results) {
|
|
329
|
+
let status = ""
|
|
330
|
+
let fg = "#aaaaaa"
|
|
331
|
+
switch (result.status) {
|
|
332
|
+
case "pending":
|
|
333
|
+
status = "⏳"
|
|
334
|
+
break
|
|
335
|
+
case "configuring":
|
|
336
|
+
status = "🔄"
|
|
337
|
+
fg = "#f1fa8c"
|
|
338
|
+
break
|
|
339
|
+
case "success":
|
|
340
|
+
status = "✓"
|
|
341
|
+
fg = "#50fa7b"
|
|
342
|
+
break
|
|
343
|
+
case "error":
|
|
344
|
+
status = "✗"
|
|
345
|
+
fg = "#ff5555"
|
|
346
|
+
break
|
|
347
|
+
case "skipped":
|
|
348
|
+
status = "⊘"
|
|
349
|
+
fg = "#6272a4"
|
|
350
|
+
break
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let content = `${status} ${result.name}`
|
|
354
|
+
if (result.message) {
|
|
355
|
+
content += ` - ${result.message}`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
this.contentBox.add(new TextRenderable(this.cliRenderer, { content: content + "\n", fg }))
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (this.currentStep === "done") {
|
|
362
|
+
this.contentBox.add(
|
|
363
|
+
new TextRenderable(this.cliRenderer, {
|
|
364
|
+
content: "\nPress Enter or Esc to continue...",
|
|
365
|
+
fg: "#6272a4",
|
|
366
|
+
})
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private cleanup(): void {
|
|
372
|
+
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
373
|
+
this.destroy()
|
|
374
|
+
this.onBack()
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -10,10 +10,9 @@ import {
|
|
|
10
10
|
} from "@opentui/core"
|
|
11
11
|
import { EasiarrConfig, AppSecret } from "../../config/schema"
|
|
12
12
|
import { getApp } from "../../apps/registry"
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { readEnv, updateEnv, getEnvPath } from "../../utils/env"
|
|
14
|
+
import { mkdir } from "node:fs/promises"
|
|
15
15
|
import { dirname } from "node:path"
|
|
16
|
-
import { existsSync } from "node:fs"
|
|
17
16
|
|
|
18
17
|
export interface SecretsEditorOptions extends BoxOptions {
|
|
19
18
|
config: EasiarrConfig
|
|
@@ -177,65 +176,25 @@ export class SecretsEditor extends BoxRenderable {
|
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
private async loadEnv() {
|
|
180
|
-
|
|
181
|
-
if (existsSync(envPath)) {
|
|
182
|
-
try {
|
|
183
|
-
const content = await readFile(envPath, "utf-8")
|
|
184
|
-
content.split("\n").forEach((line) => {
|
|
185
|
-
const parts = line.split("=")
|
|
186
|
-
if (parts.length >= 2) {
|
|
187
|
-
const key = parts[0].trim()
|
|
188
|
-
const value = parts.slice(1).join("=").trim()
|
|
189
|
-
// Remove potential quotes
|
|
190
|
-
this.envValues[key] = value.replace(/^["'](.*?)["']$/, "$1")
|
|
191
|
-
}
|
|
192
|
-
})
|
|
193
|
-
} catch (e) {
|
|
194
|
-
console.error("Failed to read .env", e)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
179
|
+
this.envValues = await readEnv()
|
|
197
180
|
}
|
|
198
181
|
|
|
199
182
|
private async save() {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// Read existing .env to preserve other values
|
|
203
|
-
const currentEnv: Record<string, string> = {}
|
|
204
|
-
if (existsSync(envPath)) {
|
|
205
|
-
try {
|
|
206
|
-
const content = await readFile(envPath, "utf-8")
|
|
207
|
-
content.split("\n").forEach((line) => {
|
|
208
|
-
const [key, ...val] = line.split("=")
|
|
209
|
-
if (key && val.length > 0) {
|
|
210
|
-
currentEnv[key.trim()] = val
|
|
211
|
-
.join("=")
|
|
212
|
-
.trim()
|
|
213
|
-
.replace(/^["'](.*?)["']$/, "$1")
|
|
214
|
-
}
|
|
215
|
-
})
|
|
216
|
-
} catch {
|
|
217
|
-
// Ignore read errors
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Update with new values from inputs
|
|
183
|
+
// Collect values from inputs
|
|
184
|
+
const updates: Record<string, string> = {}
|
|
222
185
|
this.inputs.forEach((input, key) => {
|
|
223
|
-
|
|
186
|
+
updates[key] = input.value
|
|
224
187
|
})
|
|
225
188
|
|
|
226
189
|
// Ensure directory exists
|
|
227
190
|
try {
|
|
228
|
-
await mkdir(dirname(
|
|
191
|
+
await mkdir(dirname(getEnvPath()), { recursive: true })
|
|
229
192
|
} catch {
|
|
230
193
|
// Ignore if exists
|
|
231
194
|
}
|
|
232
195
|
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
236
|
-
.join("\n")
|
|
237
|
-
|
|
238
|
-
await writeFile(envPath, envContent, "utf-8")
|
|
196
|
+
// Update .env file
|
|
197
|
+
await updateEnv(updates)
|
|
239
198
|
|
|
240
199
|
this.onSave()
|
|
241
200
|
}
|