@muhammedaksam/easiarr 0.4.2 → 0.5.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.
@@ -3,11 +3,11 @@
3
3
  * Configures Prowlarr integration with *arr apps, FlareSolverr, and proxies
4
4
  */
5
5
 
6
- import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
6
+ import { BoxRenderable, CliRenderer, TextRenderable, TextNodeRenderable, KeyEvent } from "@opentui/core"
7
7
  import { createPageLayout } from "../components/PageLayout"
8
8
  import { EasiarrConfig } from "../../config/schema"
9
9
  import { getApp } from "../../apps/registry"
10
- import { ProwlarrClient, ArrAppType } from "../../api/prowlarr-api"
10
+ import { ProwlarrClient, ArrAppType, ProwlarrIndexerSchema, PROWLARR_CATEGORIES } from "../../api/prowlarr-api"
11
11
  import { readEnvSync } from "../../utils/env"
12
12
  import { debugLog } from "../../utils/debug"
13
13
 
@@ -17,7 +17,7 @@ interface SetupResult {
17
17
  message?: string
18
18
  }
19
19
 
20
- type Step = "menu" | "sync-apps" | "flaresolverr" | "sync-profiles" | "done"
20
+ type Step = "menu" | "sync-apps" | "flaresolverr" | "sync-profiles" | "select-indexers" | "done"
21
21
 
22
22
  const ARR_APP_TYPES: Record<string, ArrAppType> = {
23
23
  radarr: "Radarr",
@@ -37,12 +37,19 @@ export class ProwlarrSetup extends BoxRenderable {
37
37
  private pageContainer!: BoxRenderable
38
38
  private menuIndex = 0
39
39
  private prowlarrClient: ProwlarrClient | null = null
40
+ private availableIndexers: ProwlarrIndexerSchema[] = []
41
+ private selectedIndexers: Set<number> = new Set() // Using index in availableIndexers array
42
+ private listScrollOffset = 0
40
43
 
41
44
  constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
42
45
  const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
43
46
  title: "Prowlarr Setup",
44
47
  stepInfo: "Configure indexer sync and proxies",
45
- footerHint: "↑↓ Navigate Enter Select Esc Back",
48
+ footerHint: [
49
+ { type: "key", key: "↑↓", value: "Navigate" },
50
+ { type: "key", key: "Enter", value: "Select" },
51
+ { type: "key", key: "Esc", value: "Back" },
52
+ ],
46
53
  })
47
54
  super(cliRenderer, { width: "100%", height: "100%" })
48
55
  this.add(pageContainer)
@@ -84,6 +91,8 @@ export class ProwlarrSetup extends BoxRenderable {
84
91
 
85
92
  if (this.currentStep === "menu") {
86
93
  this.handleMenuKeys(key)
94
+ } else if (this.currentStep === "select-indexers") {
95
+ this.handleIndexerSelectionKeys(key)
87
96
  } else if (this.currentStep === "done") {
88
97
  if (key.name === "return" || key.name === "escape") {
89
98
  this.currentStep = "menu"
@@ -121,6 +130,11 @@ export class ProwlarrSetup extends BoxRenderable {
121
130
  description: "Add Cloudflare bypass proxy",
122
131
  action: () => this.setupFlareSolverr(),
123
132
  },
133
+ {
134
+ name: "🔍 Add Public Indexers",
135
+ description: "Search and add public trackers",
136
+ action: () => this.searchIndexers(),
137
+ },
124
138
  {
125
139
  name: "📊 Create Sync Profiles",
126
140
  description: "Limited API indexer profiles",
@@ -179,7 +193,15 @@ export class ProwlarrSetup extends BoxRenderable {
179
193
  const port = app.port || appDef?.defaultPort || 7878
180
194
 
181
195
  // In Docker, use container names for inter-container communication
182
- await this.prowlarrClient.addArrApp(appType, app.id, port, apiKey, "prowlarr", prowlarrPort)
196
+ await this.prowlarrClient.addArrApp(
197
+ appType,
198
+ app.id,
199
+ port,
200
+ apiKey,
201
+ "prowlarr",
202
+ prowlarrPort,
203
+ appDef?.prowlarrCategoryIds
204
+ )
183
205
 
184
206
  const result = this.results.find((r) => r.name === app.id)
185
207
  if (result) {
@@ -272,11 +294,118 @@ export class ProwlarrSetup extends BoxRenderable {
272
294
  this.refreshContent()
273
295
  }
274
296
 
297
+ private async searchIndexers(): Promise<void> {
298
+ if (!this.prowlarrClient) return
299
+
300
+ this.currentStep = "select-indexers"
301
+ this.results = [{ name: "Fetching indexers...", status: "configuring" }]
302
+ this.refreshContent()
303
+
304
+ try {
305
+ const schemas = await this.prowlarrClient.getIndexerSchemas()
306
+ this.availableIndexers = schemas
307
+ .filter((i) => i.privacy === "public" && i.enable)
308
+ // Sort by category count descending (most capable first)
309
+ .sort((a, b) => {
310
+ const infoA = (a.capabilities?.categories || []).length
311
+ const infoB = (b.capabilities?.categories || []).length
312
+ return infoB - infoA
313
+ })
314
+
315
+ this.selectedIndexers.clear()
316
+ this.menuIndex = 0
317
+ this.listScrollOffset = 0
318
+ this.results = []
319
+ } catch (error) {
320
+ this.results = [{ name: "Error", status: "error", message: String(error) }]
321
+ this.currentStep = "done"
322
+ }
323
+ this.refreshContent()
324
+ }
325
+
326
+ private handleIndexerSelectionKeys(key: KeyEvent): void {
327
+ if (this.results.length > 0) return // If actively adding
328
+
329
+ if (key.name === "up") {
330
+ this.menuIndex = Math.max(0, this.menuIndex - 1)
331
+ if (this.menuIndex < this.listScrollOffset) {
332
+ this.listScrollOffset = this.menuIndex
333
+ }
334
+ this.refreshContent()
335
+ } else if (key.name === "down") {
336
+ this.menuIndex = Math.min(this.availableIndexers.length - 1, this.menuIndex + 1)
337
+ // Visible items = height - header (approx 15)
338
+ const visibleItems = 15
339
+ if (this.menuIndex >= this.listScrollOffset + visibleItems) {
340
+ this.listScrollOffset = this.menuIndex - visibleItems + 1
341
+ }
342
+ this.refreshContent()
343
+ } else if (key.name === "space") {
344
+ if (this.selectedIndexers.has(this.menuIndex)) {
345
+ this.selectedIndexers.delete(this.menuIndex)
346
+ } else {
347
+ this.selectedIndexers.add(this.menuIndex)
348
+ }
349
+ this.refreshContent()
350
+ } else if (key.name === "return") {
351
+ this.addSelectedIndexers()
352
+ }
353
+ }
354
+
355
+ private async addSelectedIndexers(): Promise<void> {
356
+ const toAdd = Array.from(this.selectedIndexers).map((idx) => this.availableIndexers[idx])
357
+ if (toAdd.length === 0) return
358
+
359
+ this.results = toAdd.map((i) => ({ name: i.name, status: "pending" }))
360
+ this.refreshContent()
361
+
362
+ for (const indexer of toAdd) {
363
+ // Update UI
364
+ const res = this.results.find((r) => r.name === indexer.name)
365
+ if (res) res.status = "configuring"
366
+ this.refreshContent()
367
+
368
+ try {
369
+ if (!this.prowlarrClient) throw new Error("No client")
370
+
371
+ // Auto-add FlareSolverr tag if it exists
372
+ const tags = await this.prowlarrClient.getTags()
373
+ const fsTag = tags.find((t) => t.label.toLowerCase() === "flaresolverr")
374
+
375
+ if (fsTag) {
376
+ indexer.tags = indexer.tags || []
377
+ if (!indexer.tags.includes(fsTag.id)) {
378
+ indexer.tags.push(fsTag.id)
379
+ }
380
+ }
381
+
382
+ await this.prowlarrClient.createIndexer(indexer)
383
+ if (res) {
384
+ res.status = "success"
385
+ const extra = fsTag ? " + FlareSolverr" : ""
386
+ res.message = `Added with ${indexer.capabilities?.categories?.length || 0} categories${extra}`
387
+ }
388
+ } catch (e) {
389
+ if (res) {
390
+ res.status = "error"
391
+ res.message = String(e)
392
+ }
393
+ }
394
+ this.refreshContent()
395
+ }
396
+
397
+ // After done, stay on done screen
398
+ this.currentStep = "done"
399
+ this.refreshContent()
400
+ }
401
+
275
402
  private refreshContent(): void {
276
403
  this.contentBox.getChildren().forEach((child) => child.destroy())
277
404
 
278
405
  if (this.currentStep === "menu") {
279
406
  this.renderMenu()
407
+ } else if (this.currentStep === "select-indexers" && this.results.length === 0) {
408
+ this.renderIndexerSelection()
280
409
  } else {
281
410
  this.renderResults()
282
411
  }
@@ -370,6 +499,132 @@ export class ProwlarrSetup extends BoxRenderable {
370
499
  }
371
500
  }
372
501
 
502
+ private renderIndexerSelection(): void {
503
+ const visibleHeight = 15
504
+ const endIndex = Math.min(this.availableIndexers.length, this.listScrollOffset + visibleHeight)
505
+ const items = this.availableIndexers.slice(this.listScrollOffset, endIndex)
506
+
507
+ // Calculate active category IDs from selected apps
508
+ const activeCategoryIds = new Set<number>()
509
+ this.config.apps.forEach((app) => {
510
+ const def = getApp(app.id)
511
+ def?.prowlarrCategoryIds?.forEach((id) => activeCategoryIds.add(id))
512
+ })
513
+
514
+ this.contentBox.add(
515
+ new TextRenderable(this.cliRenderer, {
516
+ content: `Select Indexers (Space to toggle, Enter to add):\n\n`,
517
+ fg: "#f1fa8c",
518
+ })
519
+ )
520
+ // Removed extra `})` and `)` here to fix syntax.
521
+ // The original instruction had an extra `})` and `)` after the first `this.contentBox.add` call.
522
+
523
+ items.forEach((idx, i) => {
524
+ const realIndex = this.listScrollOffset + i
525
+ const isSelected = this.selectedIndexers.has(realIndex)
526
+ const isCurrent = realIndex === this.menuIndex
527
+
528
+ const check = isSelected ? "[x]" : "[ ]"
529
+ const pointer = isCurrent ? "→" : " "
530
+
531
+ const cats = idx.capabilities?.categories || []
532
+
533
+ // Group capabilities
534
+ const groups = new Map<string, boolean>() // Name -> IsRelevant
535
+
536
+ // Helper to check relevance
537
+ const checkRel = (min: number, max: number) => [...activeCategoryIds].some((id) => id >= min && id < max)
538
+
539
+ // Map to track which badge colors to use
540
+ // We can pre-define colors or just cycle them, but for now let's keep the user's preferred colors if possible,
541
+ // or define a mapping.
542
+ const categoryColors: Record<string, { active: string; inactive: string }> = {
543
+ Movies: { active: "#00ffff", inactive: "#008b8b" },
544
+ TV: { active: "#ff00ff", inactive: "#8b008b" },
545
+ Audio: { active: "#00ff00", inactive: "#006400" },
546
+ Books: { active: "#50fa7b", inactive: "#00008b" },
547
+ XXX: { active: "#ff5555", inactive: "#8b0000" },
548
+ PC: { active: "#f8f8f2", inactive: "#6272a4" },
549
+ Console: { active: "#f1fa8c", inactive: "#8b8000" },
550
+ Other: { active: "#aaaaaa", inactive: "#555555" },
551
+ }
552
+
553
+ cats.forEach((c) => {
554
+ const id = c.id
555
+ let name = ""
556
+ let isRel = false
557
+
558
+ // Find parent category from static data
559
+ const parentCat = PROWLARR_CATEGORIES.find((pc) => {
560
+ // Check if id matches parent
561
+ if (pc.id === id) return true
562
+ // Check if id matches any subcategory
563
+ if (pc.subCategories?.some((sub) => sub.id === id)) return true
564
+ // Check range heuristic if needed, but the static data should cover known IDs
565
+ // Fallback to range check if no exact match found?
566
+ // Actually, the static data structure implies ranges (e.g. Movies 2000-2999)
567
+ // Let's use the ID ranges implied by the static data if possible, or just strict matching.
568
+ // The previous code used ranges. Let's try to match ranges based on the starting ID of the parent category.
569
+ // Assuming categories are 1000s blocks.
570
+ const rangeStart = Math.floor(pc.id / 1000) * 1000
571
+ if (id >= rangeStart && id < rangeStart + 1000) return true
572
+ return false
573
+ })
574
+
575
+ if (parentCat) {
576
+ name = parentCat.name
577
+ const rangeStart = Math.floor(parentCat.id / 1000) * 1000
578
+ isRel = checkRel(rangeStart, rangeStart + 1000)
579
+ }
580
+
581
+ if (name) {
582
+ groups.set(name, groups.get(name) || isRel)
583
+ }
584
+ })
585
+
586
+ const line = new TextRenderable(this.cliRenderer, { content: "" })
587
+ line.add(`${pointer} ${check} ${idx.name} `)
588
+
589
+ // Render Badge Helper
590
+ const addBadge = (name: string) => {
591
+ if (groups.has(name)) {
592
+ const isRel = groups.get(name)
593
+ const colors = categoryColors[name] || categoryColors["Other"]
594
+ const color = isRel ? colors.active : colors.inactive
595
+
596
+ const badge = new TextNodeRenderable({ fg: color })
597
+ badge.add(`[${name}] `)
598
+ if (isRel) {
599
+ badge.attributes = 1
600
+ } // Bold if supported/relevant
601
+
602
+ line.add(badge)
603
+ }
604
+ }
605
+
606
+ // Iterate through our static categories to render badges in order
607
+ PROWLARR_CATEGORIES.forEach((cat) => {
608
+ addBadge(cat.name)
609
+ })
610
+
611
+ line.add("\n")
612
+ line.fg = isCurrent ? "#ffffff" : isSelected ? "#50fa7b" : "#aaaaaa"
613
+
614
+ this.contentBox.add(line)
615
+ })
616
+
617
+ const remaining = this.availableIndexers.length - endIndex
618
+ if (remaining > 0) {
619
+ this.contentBox.add(
620
+ new TextRenderable(this.cliRenderer, {
621
+ content: `... and ${remaining} more`,
622
+ fg: "#6272a4",
623
+ })
624
+ )
625
+ }
626
+ }
627
+
373
628
  private cleanup(): void {
374
629
  this.cliRenderer.keyInput.off("keypress", this.keyHandler)
375
630
  this.destroy()
@@ -34,7 +34,10 @@ export class QBittorrentSetup extends BoxRenderable {
34
34
  const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
35
35
  title: "qBittorrent Setup",
36
36
  stepInfo: "Configure TRaSH-compliant paths and categories",
37
- footerHint: "Enter Submit Esc Back",
37
+ footerHint: [
38
+ { type: "key", key: "Enter", value: "Submit" },
39
+ { type: "key", key: "Esc", value: "Back" },
40
+ ],
38
41
  })
39
42
  super(cliRenderer, { width: "100%", height: "100%" })
40
43
  this.add(pageContainer)
@@ -106,7 +106,10 @@ export class QuickSetup {
106
106
  private renderWelcome(): void {
107
107
  const { container: page, content } = createPageLayout(this.renderer, {
108
108
  title: "Welcome to easiarr",
109
- footerHint: "Enter Select q Quit",
109
+ footerHint: [
110
+ { type: "key", key: "Enter", value: "Select" },
111
+ { type: "key", key: "q", value: "Quit" },
112
+ ],
110
113
  })
111
114
 
112
115
  // Spacer
@@ -248,7 +251,10 @@ export class QuickSetup {
248
251
 
249
252
  const { container: page, content } = createPageLayout(this.renderer, {
250
253
  title: "About easiarr",
251
- footerHint: "Esc Back q Quit",
254
+ footerHint: [
255
+ { type: "key", key: "Esc", value: "Back" },
256
+ { type: "key", key: "q", value: "Quit" },
257
+ ],
252
258
  })
253
259
 
254
260
  content.add(new TextRenderable(this.renderer, { id: "about-spacer1", content: "" }))
@@ -347,12 +353,16 @@ export class QuickSetup {
347
353
  private renderAppSelection(): void {
348
354
  const title = "Select Apps"
349
355
  const stepInfo = `${title} (${this.selectedApps.size} selected)`
350
- const footerHint = "←→ Tab Enter Toggle q Quit"
356
+ const footerHint = [
357
+ { type: "key", key: "←→", value: "Tab" },
358
+ { type: "key", key: "Enter", value: "Toggle" },
359
+ { type: "key", key: "q", value: "Quit" },
360
+ ] as const
351
361
 
352
362
  const { container: page, content } = createPageLayout(this.renderer, {
353
363
  title: title,
354
364
  stepInfo: stepInfo,
355
- footerHint: footerHint,
365
+ footerHint: [...footerHint],
356
366
  })
357
367
 
358
368
  // Application Selector
@@ -442,7 +452,12 @@ export class QuickSetup {
442
452
  const { container: page, content } = createPageLayout(this.renderer, {
443
453
  title: "System Configuration",
444
454
  stepInfo: `Step 2/${totalSteps}`,
445
- footerHint: "Tab Next Enter Next/Continue Esc Back q Quit",
455
+ footerHint: [
456
+ { type: "key", key: "Tab", value: "Next" },
457
+ { type: "key", key: "Enter", value: "Next/Continue" },
458
+ { type: "key", key: "Esc", value: "Back" },
459
+ { type: "key", key: "q", value: "Quit" },
460
+ ],
446
461
  })
447
462
 
448
463
  // Instructions
@@ -580,7 +595,11 @@ export class QuickSetup {
580
595
  const { container: page, content } = createPageLayout(this.renderer, {
581
596
  title: "VPN Configuration",
582
597
  stepInfo: `Step ${stepNum}/${totalSteps}`,
583
- footerHint: "Enter Select Esc Back q Quit",
598
+ footerHint: [
599
+ { type: "key", key: "Enter", value: "Select" },
600
+ { type: "key", key: "Esc", value: "Back" },
601
+ { type: "key", key: "q", value: "Quit" },
602
+ ],
584
603
  })
585
604
 
586
605
  content.add(
@@ -667,7 +686,12 @@ export class QuickSetup {
667
686
  const { container: page, content } = createPageLayout(this.renderer, {
668
687
  title: "Traefik Configuration",
669
688
  stepInfo: `Step ${stepNum}/${totalSteps}`,
670
- footerHint: "Tab Next Field Enter Continue Esc Back q Quit",
689
+ footerHint: [
690
+ { type: "key", key: "Tab", value: "Next Field" },
691
+ { type: "key", key: "Enter", value: "Continue" },
692
+ { type: "key", key: "Esc", value: "Back" },
693
+ { type: "key", key: "q", value: "Quit" },
694
+ ],
671
695
  })
672
696
 
673
697
  content.add(
@@ -908,7 +932,11 @@ export class QuickSetup {
908
932
  const { container: page, content } = createPageLayout(this.renderer, {
909
933
  title: "Confirm Setup",
910
934
  stepInfo: `Step ${totalSteps}/${totalSteps}`,
911
- footerHint: "Enter Select Esc Back q Quit",
935
+ footerHint: [
936
+ { type: "key", key: "Enter", value: "Select" },
937
+ { type: "key", key: "Esc", value: "Back" },
938
+ { type: "key", key: "q", value: "Quit" },
939
+ ],
912
940
  })
913
941
 
914
942
  content.add(new TextRenderable(this.renderer, { id: "confirm-spacer", content: "" }))
@@ -44,7 +44,12 @@ export class TRaSHProfileSetup extends BoxRenderable {
44
44
  const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
45
45
  title: "TRaSH Guide Setup",
46
46
  stepInfo: "Configure quality profiles and custom formats",
47
- footerHint: "↑↓ Navigate Space Select Enter Confirm Esc Back",
47
+ footerHint: [
48
+ { type: "key", key: "↑↓", value: "Navigate" },
49
+ { type: "key", key: "Space", value: "Select" },
50
+ { type: "key", key: "Enter", value: "Confirm" },
51
+ { type: "key", key: "Esc", value: "Back" },
52
+ ],
48
53
  })
49
54
  super(cliRenderer, { width: "100%", height: "100%" })
50
55
  this.add(pageContainer)