@muhammedaksam/easiarr 0.4.1 → 0.4.3

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.4.1",
3
+ "version": "0.4.3",
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",
@@ -14,6 +14,33 @@ export interface IndexerProxy {
14
14
  fields: { name: string; value: unknown }[]
15
15
  }
16
16
 
17
+ export interface ProwlarrIndexerSchema {
18
+ id?: number
19
+ name: string
20
+ implementation: string
21
+ configContract: string
22
+ fields: { name: string; value?: unknown }[]
23
+ tags: number[]
24
+ enable: boolean
25
+ privacy: "public" | "private" | "semi-private"
26
+ protocol: "torrent" | "usenet"
27
+ priority: number
28
+ capabilities?: {
29
+ categories: { id: number; name: string; subCategories?: { id: number; name: string }[] }[]
30
+ }
31
+ }
32
+
33
+ export interface ProwlarrIndexer {
34
+ id?: number
35
+ name: string
36
+ fields: { name: string; value?: unknown }[]
37
+ tags: number[]
38
+ enable: boolean
39
+ protocol: string
40
+ implementation: string
41
+ configContract: string
42
+ }
43
+
17
44
  export interface SyncProfile {
18
45
  id?: number
19
46
  name: string
@@ -188,6 +215,28 @@ export class ProwlarrClient {
188
215
  await this.request(`/indexerproxy/${id}`, { method: "DELETE" })
189
216
  }
190
217
 
218
+ // Indexer Management
219
+ async getIndexerSchemas(): Promise<ProwlarrIndexerSchema[]> {
220
+ return this.request<ProwlarrIndexerSchema[]>("/indexer/schema")
221
+ }
222
+
223
+ async getIndexers(): Promise<ProwlarrIndexer[]> {
224
+ return this.request<ProwlarrIndexer[]>("/indexer")
225
+ }
226
+
227
+ async createIndexer(indexer: ProwlarrIndexerSchema): Promise<ProwlarrIndexer> {
228
+ // Ensure required fields are set
229
+ const payload = {
230
+ ...indexer,
231
+ id: undefined, // Create new
232
+ appProfileId: 1, // Default profile
233
+ }
234
+ return this.request<ProwlarrIndexer>("/indexer", {
235
+ method: "POST",
236
+ body: JSON.stringify(payload),
237
+ })
238
+ }
239
+
191
240
  // Sync Profile management (aka App Sync Profile)
192
241
  async getSyncProfiles(): Promise<SyncProfile[]> {
193
242
  return this.request<SyncProfile[]>("/appsyncprofile")
@@ -258,23 +307,14 @@ export class ProwlarrClient {
258
307
  prowlarrUrl: string,
259
308
  appUrl: string,
260
309
  appApiKey: string,
261
- syncLevel: "disabled" | "addOnly" | "fullSync" = "fullSync"
310
+ syncLevel: "disabled" | "addOnly" | "fullSync" = "fullSync",
311
+ syncCategories: number[] = []
262
312
  ): Promise<Application> {
263
- // Default sync categories for each app type
264
- // Radarr: Movies (2000, 2010, etc), Sonarr: TV (5000, 5010, etc)
265
- // Lidarr: Audio (3000), Readarr: Books (7000, 8010)
266
- const syncCategoriesMap: Record<ArrAppType, number[]> = {
267
- Radarr: [2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080],
268
- Sonarr: [5000, 5010, 5020, 5030, 5040, 5045, 5050, 5060, 5070, 5080],
269
- Lidarr: [3000, 3010, 3020, 3030, 3040],
270
- Readarr: [7000, 7010, 7020, 7030, 8000, 8010, 8020],
271
- }
272
-
273
313
  const fields: { name: string; value: unknown }[] = [
274
314
  { name: "prowlarrUrl", value: prowlarrUrl },
275
315
  { name: "baseUrl", value: appUrl },
276
316
  { name: "apiKey", value: appApiKey },
277
- { name: "syncCategories", value: syncCategoriesMap[appType] || [] },
317
+ { name: "syncCategories", value: syncCategories },
278
318
  ]
279
319
 
280
320
  return this.request<Application>("/applications", {
@@ -309,7 +349,8 @@ export class ProwlarrClient {
309
349
  port: number,
310
350
  apiKey: string,
311
351
  prowlarrHost: string,
312
- prowlarrPort: number
352
+ prowlarrPort: number,
353
+ syncCategories?: number[]
313
354
  ): Promise<Application> {
314
355
  const prowlarrUrl = `http://${prowlarrHost}:${prowlarrPort}`
315
356
  const appUrl = `http://${host}:${port}`
@@ -321,6 +362,398 @@ export class ProwlarrClient {
321
362
  return existing
322
363
  }
323
364
 
324
- return this.addApplication(appType, appType, prowlarrUrl, appUrl, apiKey)
365
+ return this.addApplication(appType, appType, prowlarrUrl, appUrl, apiKey, "fullSync", syncCategories)
325
366
  }
326
367
  }
368
+
369
+ export const PROWLARR_CATEGORIES = [
370
+ {
371
+ id: 1000,
372
+ name: "Console",
373
+ subCategories: [
374
+ {
375
+ id: 1010,
376
+ name: "Console/NDS",
377
+ subCategories: [],
378
+ },
379
+ {
380
+ id: 1020,
381
+ name: "Console/PSP",
382
+ subCategories: [],
383
+ },
384
+ {
385
+ id: 1030,
386
+ name: "Console/Wii",
387
+ subCategories: [],
388
+ },
389
+ {
390
+ id: 1040,
391
+ name: "Console/XBox",
392
+ subCategories: [],
393
+ },
394
+ {
395
+ id: 1050,
396
+ name: "Console/XBox 360",
397
+ subCategories: [],
398
+ },
399
+ {
400
+ id: 1060,
401
+ name: "Console/Wiiware",
402
+ subCategories: [],
403
+ },
404
+ {
405
+ id: 1070,
406
+ name: "Console/XBox 360 DLC",
407
+ subCategories: [],
408
+ },
409
+ {
410
+ id: 1080,
411
+ name: "Console/PS3",
412
+ subCategories: [],
413
+ },
414
+ {
415
+ id: 1090,
416
+ name: "Console/Other",
417
+ subCategories: [],
418
+ },
419
+ {
420
+ id: 1110,
421
+ name: "Console/3DS",
422
+ subCategories: [],
423
+ },
424
+ {
425
+ id: 1120,
426
+ name: "Console/PS Vita",
427
+ subCategories: [],
428
+ },
429
+ {
430
+ id: 1130,
431
+ name: "Console/WiiU",
432
+ subCategories: [],
433
+ },
434
+ {
435
+ id: 1140,
436
+ name: "Console/XBox One",
437
+ subCategories: [],
438
+ },
439
+ {
440
+ id: 1180,
441
+ name: "Console/PS4",
442
+ subCategories: [],
443
+ },
444
+ ],
445
+ },
446
+ {
447
+ id: 2000,
448
+ name: "Movies",
449
+ subCategories: [
450
+ {
451
+ id: 2010,
452
+ name: "Movies/Foreign",
453
+ subCategories: [],
454
+ },
455
+ {
456
+ id: 2020,
457
+ name: "Movies/Other",
458
+ subCategories: [],
459
+ },
460
+ {
461
+ id: 2030,
462
+ name: "Movies/SD",
463
+ subCategories: [],
464
+ },
465
+ {
466
+ id: 2040,
467
+ name: "Movies/HD",
468
+ subCategories: [],
469
+ },
470
+ {
471
+ id: 2045,
472
+ name: "Movies/UHD",
473
+ subCategories: [],
474
+ },
475
+ {
476
+ id: 2050,
477
+ name: "Movies/BluRay",
478
+ subCategories: [],
479
+ },
480
+ {
481
+ id: 2060,
482
+ name: "Movies/3D",
483
+ subCategories: [],
484
+ },
485
+ {
486
+ id: 2070,
487
+ name: "Movies/DVD",
488
+ subCategories: [],
489
+ },
490
+ {
491
+ id: 2080,
492
+ name: "Movies/WEB-DL",
493
+ subCategories: [],
494
+ },
495
+ {
496
+ id: 2090,
497
+ name: "Movies/x265",
498
+ subCategories: [],
499
+ },
500
+ ],
501
+ },
502
+ {
503
+ id: 3000,
504
+ name: "Audio",
505
+ subCategories: [
506
+ {
507
+ id: 3010,
508
+ name: "Audio/MP3",
509
+ subCategories: [],
510
+ },
511
+ {
512
+ id: 3020,
513
+ name: "Audio/Video",
514
+ subCategories: [],
515
+ },
516
+ {
517
+ id: 3030,
518
+ name: "Audio/Audiobook",
519
+ subCategories: [],
520
+ },
521
+ {
522
+ id: 3040,
523
+ name: "Audio/Lossless",
524
+ subCategories: [],
525
+ },
526
+ {
527
+ id: 3050,
528
+ name: "Audio/Other",
529
+ subCategories: [],
530
+ },
531
+ {
532
+ id: 3060,
533
+ name: "Audio/Foreign",
534
+ subCategories: [],
535
+ },
536
+ ],
537
+ },
538
+ {
539
+ id: 4000,
540
+ name: "PC",
541
+ subCategories: [
542
+ {
543
+ id: 4010,
544
+ name: "PC/0day",
545
+ subCategories: [],
546
+ },
547
+ {
548
+ id: 4020,
549
+ name: "PC/ISO",
550
+ subCategories: [],
551
+ },
552
+ {
553
+ id: 4030,
554
+ name: "PC/Mac",
555
+ subCategories: [],
556
+ },
557
+ {
558
+ id: 4040,
559
+ name: "PC/Mobile-Other",
560
+ subCategories: [],
561
+ },
562
+ {
563
+ id: 4050,
564
+ name: "PC/Games",
565
+ subCategories: [],
566
+ },
567
+ {
568
+ id: 4060,
569
+ name: "PC/Mobile-iOS",
570
+ subCategories: [],
571
+ },
572
+ {
573
+ id: 4070,
574
+ name: "PC/Mobile-Android",
575
+ subCategories: [],
576
+ },
577
+ ],
578
+ },
579
+ {
580
+ id: 5000,
581
+ name: "TV",
582
+ subCategories: [
583
+ {
584
+ id: 5010,
585
+ name: "TV/WEB-DL",
586
+ subCategories: [],
587
+ },
588
+ {
589
+ id: 5020,
590
+ name: "TV/Foreign",
591
+ subCategories: [],
592
+ },
593
+ {
594
+ id: 5030,
595
+ name: "TV/SD",
596
+ subCategories: [],
597
+ },
598
+ {
599
+ id: 5040,
600
+ name: "TV/HD",
601
+ subCategories: [],
602
+ },
603
+ {
604
+ id: 5045,
605
+ name: "TV/UHD",
606
+ subCategories: [],
607
+ },
608
+ {
609
+ id: 5050,
610
+ name: "TV/Other",
611
+ subCategories: [],
612
+ },
613
+ {
614
+ id: 5060,
615
+ name: "TV/Sport",
616
+ subCategories: [],
617
+ },
618
+ {
619
+ id: 5070,
620
+ name: "TV/Anime",
621
+ subCategories: [],
622
+ },
623
+ {
624
+ id: 5080,
625
+ name: "TV/Documentary",
626
+ subCategories: [],
627
+ },
628
+ {
629
+ id: 5090,
630
+ name: "TV/x265",
631
+ subCategories: [],
632
+ },
633
+ ],
634
+ },
635
+ {
636
+ id: 6000,
637
+ name: "XXX",
638
+ subCategories: [
639
+ {
640
+ id: 6010,
641
+ name: "XXX/DVD",
642
+ subCategories: [],
643
+ },
644
+ {
645
+ id: 6020,
646
+ name: "XXX/WMV",
647
+ subCategories: [],
648
+ },
649
+ {
650
+ id: 6030,
651
+ name: "XXX/XviD",
652
+ subCategories: [],
653
+ },
654
+ {
655
+ id: 6040,
656
+ name: "XXX/x264",
657
+ subCategories: [],
658
+ },
659
+ {
660
+ id: 6045,
661
+ name: "XXX/UHD",
662
+ subCategories: [],
663
+ },
664
+ {
665
+ id: 6050,
666
+ name: "XXX/Pack",
667
+ subCategories: [],
668
+ },
669
+ {
670
+ id: 6060,
671
+ name: "XXX/ImageSet",
672
+ subCategories: [],
673
+ },
674
+ {
675
+ id: 6070,
676
+ name: "XXX/Other",
677
+ subCategories: [],
678
+ },
679
+ {
680
+ id: 6080,
681
+ name: "XXX/SD",
682
+ subCategories: [],
683
+ },
684
+ {
685
+ id: 6090,
686
+ name: "XXX/WEB-DL",
687
+ subCategories: [],
688
+ },
689
+ ],
690
+ },
691
+ {
692
+ id: 7000,
693
+ name: "Books",
694
+ subCategories: [
695
+ {
696
+ id: 7010,
697
+ name: "Books/Mags",
698
+ subCategories: [],
699
+ },
700
+ {
701
+ id: 7020,
702
+ name: "Books/EBook",
703
+ subCategories: [],
704
+ },
705
+ {
706
+ id: 7030,
707
+ name: "Books/Comics",
708
+ subCategories: [],
709
+ },
710
+ {
711
+ id: 7040,
712
+ name: "Books/Technical",
713
+ subCategories: [],
714
+ },
715
+ {
716
+ id: 7050,
717
+ name: "Books/Other",
718
+ subCategories: [],
719
+ },
720
+ {
721
+ id: 7060,
722
+ name: "Books/Foreign",
723
+ subCategories: [],
724
+ },
725
+ ],
726
+ },
727
+ {
728
+ id: 8000,
729
+ name: "Other",
730
+ subCategories: [
731
+ {
732
+ id: 8010,
733
+ name: "Other/Misc",
734
+ subCategories: [],
735
+ },
736
+ {
737
+ id: 8020,
738
+ name: "Other/Hashed",
739
+ subCategories: [],
740
+ },
741
+ ],
742
+ },
743
+ {
744
+ id: 0,
745
+ name: "Other",
746
+ subCategories: [
747
+ {
748
+ id: 10,
749
+ name: "Other/Misc",
750
+ subCategories: [],
751
+ },
752
+ {
753
+ id: 20,
754
+ name: "Other/Hashed",
755
+ subCategories: [],
756
+ },
757
+ ],
758
+ },
759
+ ]
@@ -178,21 +178,33 @@ export class QBittorrentClient {
178
178
 
179
179
  /**
180
180
  * Configure qBittorrent for TRaSH Guide compliance
181
- * Sets proper save paths and creates categories based on enabled apps
181
+ * Sets proper save paths and creates categories based on enabled *arr apps
182
182
  * @param categories - Array of {name, savePath} for each enabled *arr app
183
+ * @param auth - Optional credentials to enforce (update username/password)
183
184
  */
184
- async configureTRaSHCompliant(categories: QBittorrentCategory[] = []): Promise<void> {
185
+ async configureTRaSHCompliant(
186
+ categories: QBittorrentCategory[] = [],
187
+ auth?: { user: string; pass: string }
188
+ ): Promise<void> {
185
189
  debugLog("qBittorrent", "Configuring TRaSH-compliant settings")
186
190
 
187
191
  // 1. Set global preferences
188
192
  debugLog("qBittorrent", "Setting save_path to /data/torrents")
189
- await this.setPreferences({
193
+ const prefs: Record<string, unknown> = {
190
194
  save_path: "/data/torrents",
191
195
  temp_path_enabled: false,
192
196
  auto_tmm_enabled: true,
193
197
  category_changed_tmm_enabled: true,
194
198
  save_path_changed_tmm_enabled: true,
195
- })
199
+ }
200
+
201
+ if (auth) {
202
+ debugLog("qBittorrent", "Setting WebUI username/password")
203
+ prefs.web_ui_username = auth.user
204
+ prefs.web_ui_password = auth.pass
205
+ }
206
+
207
+ await this.setPreferences(prefs)
196
208
 
197
209
  // 2. Create categories for each enabled media type
198
210
  for (const cat of categories) {
@@ -29,6 +29,7 @@ export const APPS: Record<AppId, AppDefinition> = {
29
29
  path: "/data/media/movies",
30
30
  apiVersion: "v3",
31
31
  },
32
+ prowlarrCategoryIds: [2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080, 2090],
32
33
  },
33
34
 
34
35
  sonarr: {
@@ -52,6 +53,7 @@ export const APPS: Record<AppId, AppDefinition> = {
52
53
  path: "/data/media/tv",
53
54
  apiVersion: "v3",
54
55
  },
56
+ prowlarrCategoryIds: [5000, 5010, 5020, 5030, 5040, 5045, 5050, 5060, 5070, 5080, 5090],
55
57
  },
56
58
 
57
59
  lidarr: {
@@ -73,6 +75,7 @@ export const APPS: Record<AppId, AppDefinition> = {
73
75
  path: "/data/media/music",
74
76
  apiVersion: "v1",
75
77
  },
78
+ prowlarrCategoryIds: [3000, 3010, 3020, 3030, 3040, 3050, 3060],
76
79
  },
77
80
 
78
81
  readarr: {
@@ -94,6 +97,7 @@ export const APPS: Record<AppId, AppDefinition> = {
94
97
  path: "/data/media/books",
95
98
  apiVersion: "v1",
96
99
  },
100
+ prowlarrCategoryIds: [7000, 7010, 7020, 7030, 7040, 7050, 7060],
97
101
  arch: {
98
102
  deprecated: ["arm64", "arm32"],
99
103
  warning: "Readarr is deprecated - no ARM64 support (project abandoned by upstream)",
@@ -161,6 +165,7 @@ export const APPS: Record<AppId, AppDefinition> = {
161
165
  path: "/data/media/adult",
162
166
  apiVersion: "v3",
163
167
  },
168
+ prowlarrCategoryIds: [6000, 6010, 6020, 6030, 6040, 6045, 6050, 6060, 6070, 6080, 6090],
164
169
  },
165
170
 
166
171
  audiobookshelf: {
@@ -152,6 +152,7 @@ export interface AppDefinition {
152
152
  cap_add?: string[]
153
153
  apiKeyMeta?: ApiKeyMeta
154
154
  rootFolder?: RootFolderMeta
155
+ prowlarrCategoryIds?: number[]
155
156
  /** Architecture compatibility info - omit if supports all */
156
157
  arch?: ArchCompatibility
157
158
  }
@@ -319,7 +319,7 @@ export class FullAutoSetup extends BoxRenderable {
319
319
  savePath: `/data/torrents/${cat.name}`,
320
320
  }))
321
321
 
322
- await client.configureTRaSHCompliant(categories)
322
+ await client.configureTRaSHCompliant(categories, { user, pass })
323
323
  this.updateStep("qBittorrent", "success")
324
324
  } catch (e) {
325
325
  this.updateStep("qBittorrent", "error", `${e}`)
@@ -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,6 +37,9 @@ 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, {
@@ -84,6 +87,8 @@ export class ProwlarrSetup extends BoxRenderable {
84
87
 
85
88
  if (this.currentStep === "menu") {
86
89
  this.handleMenuKeys(key)
90
+ } else if (this.currentStep === "select-indexers") {
91
+ this.handleIndexerSelectionKeys(key)
87
92
  } else if (this.currentStep === "done") {
88
93
  if (key.name === "return" || key.name === "escape") {
89
94
  this.currentStep = "menu"
@@ -121,6 +126,11 @@ export class ProwlarrSetup extends BoxRenderable {
121
126
  description: "Add Cloudflare bypass proxy",
122
127
  action: () => this.setupFlareSolverr(),
123
128
  },
129
+ {
130
+ name: "🔍 Add Public Indexers",
131
+ description: "Search and add public trackers",
132
+ action: () => this.searchIndexers(),
133
+ },
124
134
  {
125
135
  name: "📊 Create Sync Profiles",
126
136
  description: "Limited API indexer profiles",
@@ -179,7 +189,15 @@ export class ProwlarrSetup extends BoxRenderable {
179
189
  const port = app.port || appDef?.defaultPort || 7878
180
190
 
181
191
  // In Docker, use container names for inter-container communication
182
- await this.prowlarrClient.addArrApp(appType, app.id, port, apiKey, "prowlarr", prowlarrPort)
192
+ await this.prowlarrClient.addArrApp(
193
+ appType,
194
+ app.id,
195
+ port,
196
+ apiKey,
197
+ "prowlarr",
198
+ prowlarrPort,
199
+ appDef?.prowlarrCategoryIds
200
+ )
183
201
 
184
202
  const result = this.results.find((r) => r.name === app.id)
185
203
  if (result) {
@@ -272,11 +290,118 @@ export class ProwlarrSetup extends BoxRenderable {
272
290
  this.refreshContent()
273
291
  }
274
292
 
293
+ private async searchIndexers(): Promise<void> {
294
+ if (!this.prowlarrClient) return
295
+
296
+ this.currentStep = "select-indexers"
297
+ this.results = [{ name: "Fetching indexers...", status: "configuring" }]
298
+ this.refreshContent()
299
+
300
+ try {
301
+ const schemas = await this.prowlarrClient.getIndexerSchemas()
302
+ this.availableIndexers = schemas
303
+ .filter((i) => i.privacy === "public" && i.enable)
304
+ // Sort by category count descending (most capable first)
305
+ .sort((a, b) => {
306
+ const infoA = (a.capabilities?.categories || []).length
307
+ const infoB = (b.capabilities?.categories || []).length
308
+ return infoB - infoA
309
+ })
310
+
311
+ this.selectedIndexers.clear()
312
+ this.menuIndex = 0
313
+ this.listScrollOffset = 0
314
+ this.results = []
315
+ } catch (error) {
316
+ this.results = [{ name: "Error", status: "error", message: String(error) }]
317
+ this.currentStep = "done"
318
+ }
319
+ this.refreshContent()
320
+ }
321
+
322
+ private handleIndexerSelectionKeys(key: KeyEvent): void {
323
+ if (this.results.length > 0) return // If actively adding
324
+
325
+ if (key.name === "up") {
326
+ this.menuIndex = Math.max(0, this.menuIndex - 1)
327
+ if (this.menuIndex < this.listScrollOffset) {
328
+ this.listScrollOffset = this.menuIndex
329
+ }
330
+ this.refreshContent()
331
+ } else if (key.name === "down") {
332
+ this.menuIndex = Math.min(this.availableIndexers.length - 1, this.menuIndex + 1)
333
+ // Visible items = height - header (approx 15)
334
+ const visibleItems = 15
335
+ if (this.menuIndex >= this.listScrollOffset + visibleItems) {
336
+ this.listScrollOffset = this.menuIndex - visibleItems + 1
337
+ }
338
+ this.refreshContent()
339
+ } else if (key.name === "space") {
340
+ if (this.selectedIndexers.has(this.menuIndex)) {
341
+ this.selectedIndexers.delete(this.menuIndex)
342
+ } else {
343
+ this.selectedIndexers.add(this.menuIndex)
344
+ }
345
+ this.refreshContent()
346
+ } else if (key.name === "return") {
347
+ this.addSelectedIndexers()
348
+ }
349
+ }
350
+
351
+ private async addSelectedIndexers(): Promise<void> {
352
+ const toAdd = Array.from(this.selectedIndexers).map((idx) => this.availableIndexers[idx])
353
+ if (toAdd.length === 0) return
354
+
355
+ this.results = toAdd.map((i) => ({ name: i.name, status: "pending" }))
356
+ this.refreshContent()
357
+
358
+ for (const indexer of toAdd) {
359
+ // Update UI
360
+ const res = this.results.find((r) => r.name === indexer.name)
361
+ if (res) res.status = "configuring"
362
+ this.refreshContent()
363
+
364
+ try {
365
+ if (!this.prowlarrClient) throw new Error("No client")
366
+
367
+ // Auto-add FlareSolverr tag if it exists
368
+ const tags = await this.prowlarrClient.getTags()
369
+ const fsTag = tags.find((t) => t.label.toLowerCase() === "flaresolverr")
370
+
371
+ if (fsTag) {
372
+ indexer.tags = indexer.tags || []
373
+ if (!indexer.tags.includes(fsTag.id)) {
374
+ indexer.tags.push(fsTag.id)
375
+ }
376
+ }
377
+
378
+ await this.prowlarrClient.createIndexer(indexer)
379
+ if (res) {
380
+ res.status = "success"
381
+ const extra = fsTag ? " + FlareSolverr" : ""
382
+ res.message = `Added with ${indexer.capabilities?.categories?.length || 0} categories${extra}`
383
+ }
384
+ } catch (e) {
385
+ if (res) {
386
+ res.status = "error"
387
+ res.message = String(e)
388
+ }
389
+ }
390
+ this.refreshContent()
391
+ }
392
+
393
+ // After done, stay on done screen
394
+ this.currentStep = "done"
395
+ this.refreshContent()
396
+ }
397
+
275
398
  private refreshContent(): void {
276
399
  this.contentBox.getChildren().forEach((child) => child.destroy())
277
400
 
278
401
  if (this.currentStep === "menu") {
279
402
  this.renderMenu()
403
+ } else if (this.currentStep === "select-indexers" && this.results.length === 0) {
404
+ this.renderIndexerSelection()
280
405
  } else {
281
406
  this.renderResults()
282
407
  }
@@ -370,6 +495,132 @@ export class ProwlarrSetup extends BoxRenderable {
370
495
  }
371
496
  }
372
497
 
498
+ private renderIndexerSelection(): void {
499
+ const visibleHeight = 15
500
+ const endIndex = Math.min(this.availableIndexers.length, this.listScrollOffset + visibleHeight)
501
+ const items = this.availableIndexers.slice(this.listScrollOffset, endIndex)
502
+
503
+ // Calculate active category IDs from selected apps
504
+ const activeCategoryIds = new Set<number>()
505
+ this.config.apps.forEach((app) => {
506
+ const def = getApp(app.id)
507
+ def?.prowlarrCategoryIds?.forEach((id) => activeCategoryIds.add(id))
508
+ })
509
+
510
+ this.contentBox.add(
511
+ new TextRenderable(this.cliRenderer, {
512
+ content: `Select Indexers (Space to toggle, Enter to add):\n\n`,
513
+ fg: "#f1fa8c",
514
+ })
515
+ )
516
+ // Removed extra `})` and `)` here to fix syntax.
517
+ // The original instruction had an extra `})` and `)` after the first `this.contentBox.add` call.
518
+
519
+ items.forEach((idx, i) => {
520
+ const realIndex = this.listScrollOffset + i
521
+ const isSelected = this.selectedIndexers.has(realIndex)
522
+ const isCurrent = realIndex === this.menuIndex
523
+
524
+ const check = isSelected ? "[x]" : "[ ]"
525
+ const pointer = isCurrent ? "→" : " "
526
+
527
+ const cats = idx.capabilities?.categories || []
528
+
529
+ // Group capabilities
530
+ const groups = new Map<string, boolean>() // Name -> IsRelevant
531
+
532
+ // Helper to check relevance
533
+ const checkRel = (min: number, max: number) => [...activeCategoryIds].some((id) => id >= min && id < max)
534
+
535
+ // Map to track which badge colors to use
536
+ // We can pre-define colors or just cycle them, but for now let's keep the user's preferred colors if possible,
537
+ // or define a mapping.
538
+ const categoryColors: Record<string, { active: string; inactive: string }> = {
539
+ Movies: { active: "#00ffff", inactive: "#008b8b" },
540
+ TV: { active: "#ff00ff", inactive: "#8b008b" },
541
+ Audio: { active: "#00ff00", inactive: "#006400" },
542
+ Books: { active: "#50fa7b", inactive: "#00008b" },
543
+ XXX: { active: "#ff5555", inactive: "#8b0000" },
544
+ PC: { active: "#f8f8f2", inactive: "#6272a4" },
545
+ Console: { active: "#f1fa8c", inactive: "#8b8000" },
546
+ Other: { active: "#aaaaaa", inactive: "#555555" },
547
+ }
548
+
549
+ cats.forEach((c) => {
550
+ const id = c.id
551
+ let name = ""
552
+ let isRel = false
553
+
554
+ // Find parent category from static data
555
+ const parentCat = PROWLARR_CATEGORIES.find((pc) => {
556
+ // Check if id matches parent
557
+ if (pc.id === id) return true
558
+ // Check if id matches any subcategory
559
+ if (pc.subCategories?.some((sub) => sub.id === id)) return true
560
+ // Check range heuristic if needed, but the static data should cover known IDs
561
+ // Fallback to range check if no exact match found?
562
+ // Actually, the static data structure implies ranges (e.g. Movies 2000-2999)
563
+ // Let's use the ID ranges implied by the static data if possible, or just strict matching.
564
+ // The previous code used ranges. Let's try to match ranges based on the starting ID of the parent category.
565
+ // Assuming categories are 1000s blocks.
566
+ const rangeStart = Math.floor(pc.id / 1000) * 1000
567
+ if (id >= rangeStart && id < rangeStart + 1000) return true
568
+ return false
569
+ })
570
+
571
+ if (parentCat) {
572
+ name = parentCat.name
573
+ const rangeStart = Math.floor(parentCat.id / 1000) * 1000
574
+ isRel = checkRel(rangeStart, rangeStart + 1000)
575
+ }
576
+
577
+ if (name) {
578
+ groups.set(name, groups.get(name) || isRel)
579
+ }
580
+ })
581
+
582
+ const line = new TextRenderable(this.cliRenderer, { content: "" })
583
+ line.add(`${pointer} ${check} ${idx.name} `)
584
+
585
+ // Render Badge Helper
586
+ const addBadge = (name: string) => {
587
+ if (groups.has(name)) {
588
+ const isRel = groups.get(name)
589
+ const colors = categoryColors[name] || categoryColors["Other"]
590
+ const color = isRel ? colors.active : colors.inactive
591
+
592
+ const badge = new TextNodeRenderable({ fg: color })
593
+ badge.add(`[${name}] `)
594
+ if (isRel) {
595
+ badge.attributes = 1
596
+ } // Bold if supported/relevant
597
+
598
+ line.add(badge)
599
+ }
600
+ }
601
+
602
+ // Iterate through our static categories to render badges in order
603
+ PROWLARR_CATEGORIES.forEach((cat) => {
604
+ addBadge(cat.name)
605
+ })
606
+
607
+ line.add("\n")
608
+ line.fg = isCurrent ? "#ffffff" : isSelected ? "#50fa7b" : "#aaaaaa"
609
+
610
+ this.contentBox.add(line)
611
+ })
612
+
613
+ const remaining = this.availableIndexers.length - endIndex
614
+ if (remaining > 0) {
615
+ this.contentBox.add(
616
+ new TextRenderable(this.cliRenderer, {
617
+ content: `... and ${remaining} more`,
618
+ fg: "#6272a4",
619
+ })
620
+ )
621
+ }
622
+ }
623
+
373
624
  private cleanup(): void {
374
625
  this.cliRenderer.keyInput.off("keypress", this.keyHandler)
375
626
  this.destroy()
@@ -162,7 +162,7 @@ export class QBittorrentSetup extends BoxRenderable {
162
162
  savePath: `/data/torrents/${cat.name}`,
163
163
  }))
164
164
 
165
- await client.configureTRaSHCompliant(categories)
165
+ await client.configureTRaSHCompliant(categories, { user: this.user, pass: this.pass })
166
166
 
167
167
  const catNames = categories.map((c) => c.name).join(", ") || "none"
168
168
  this.statusMessage = `✅ Done!\n\n save_path: /data/torrents\n Categories: ${catNames}\n\n Press Enter to continue.`