@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.
@@ -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 { getComposePath } from "../../config/manager"
14
- import { readFile, writeFile, mkdir } from "node:fs/promises"
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
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
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
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
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
- currentEnv[key] = input.value
186
+ updates[key] = input.value
224
187
  })
225
188
 
226
189
  // Ensure directory exists
227
190
  try {
228
- await mkdir(dirname(envPath), { recursive: true })
191
+ await mkdir(dirname(getEnvPath()), { recursive: true })
229
192
  } catch {
230
193
  // Ignore if exists
231
194
  }
232
195
 
233
- // Write back
234
- const envContent = Object.entries(currentEnv)
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
  }