@muhammedaksam/easiarr 1.1.0 → 1.1.2

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,622 @@
1
+ /**
2
+ * Huntarr API Client
3
+ * Provides health check, version info, and auto-setup for Huntarr
4
+ * Configures Sonarr, Radarr, Lidarr, Readarr, Whisparr instances automatically
5
+ */
6
+
7
+ import { debugLog } from "../utils/debug"
8
+ import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
9
+ import type { AppConfig } from "../config/schema"
10
+
11
+ interface HuntarrVersion {
12
+ version: string
13
+ }
14
+
15
+ interface TestConnectionResult {
16
+ success: boolean
17
+ message?: string
18
+ version?: string
19
+ }
20
+
21
+ /** Huntarr instance configuration */
22
+ interface HuntarrInstance {
23
+ name: string
24
+ api_url: string
25
+ api_key: string
26
+ enabled?: boolean
27
+ [key: string]: unknown
28
+ }
29
+
30
+ /** Huntarr app settings with instances array */
31
+ interface HuntarrAppSettings {
32
+ instances?: HuntarrInstance[]
33
+ [key: string]: unknown
34
+ }
35
+
36
+ /** Huntarr-supported *arr app types */
37
+ const HUNTARR_APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr"] as const
38
+ type HuntarrAppType = (typeof HUNTARR_APP_TYPES)[number]
39
+
40
+ export class HuntarrClient implements IAutoSetupClient {
41
+ private host: string
42
+ private port: number
43
+ private sessionCookie: string | null = null
44
+
45
+ constructor(host: string, port: number = 9705) {
46
+ this.host = host
47
+ this.port = port
48
+ }
49
+
50
+ /**
51
+ * Get base URL for Huntarr
52
+ */
53
+ private get baseUrl(): string {
54
+ return `http://${this.host}:${this.port}`
55
+ }
56
+
57
+ /**
58
+ * Get headers with session cookie if available
59
+ */
60
+ private getHeaders(): Record<string, string> {
61
+ const headers: Record<string, string> = {
62
+ "Content-Type": "application/json",
63
+ Accept: "application/json",
64
+ }
65
+ if (this.sessionCookie) {
66
+ headers["Cookie"] = `huntarr_session=${this.sessionCookie}`
67
+ }
68
+ return headers
69
+ }
70
+
71
+ /**
72
+ * Check if a user has been created in Huntarr
73
+ */
74
+ async userExists(): Promise<boolean> {
75
+ try {
76
+ const response = await fetch(`${this.baseUrl}/api/setup/status`, {
77
+ method: "GET",
78
+ signal: AbortSignal.timeout(5000),
79
+ })
80
+ const data = await response.json()
81
+ return data.user_exists === true
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Create a user in Huntarr (first-time setup)
89
+ */
90
+ async createUser(username: string, password: string): Promise<boolean> {
91
+ try {
92
+ const response = await fetch(`${this.baseUrl}/setup`, {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/json" },
95
+ body: JSON.stringify({
96
+ username,
97
+ password,
98
+ confirm_password: password,
99
+ }),
100
+ signal: AbortSignal.timeout(10000),
101
+ })
102
+
103
+ if (response.ok) {
104
+ // Extract session cookie from response
105
+ const setCookie = response.headers.get("set-cookie")
106
+ if (setCookie) {
107
+ const match = setCookie.match(/huntarr_session=([^;]+)/)
108
+ if (match) {
109
+ this.sessionCookie = match[1]
110
+ debugLog("HuntarrApi", "Created user and got session cookie")
111
+ }
112
+ }
113
+
114
+ // Complete setup wizard and enable local access bypass for Homepage
115
+ await this.completeSetup(username)
116
+ await this.enableLocalAccessBypass()
117
+ return true
118
+ }
119
+
120
+ // Log error details
121
+ const errorBody = await response.text()
122
+ debugLog("HuntarrApi", `Create user failed: ${response.status} - ${errorBody}`)
123
+ } catch (error) {
124
+ debugLog("HuntarrApi", `Create user error: ${error}`)
125
+ }
126
+ return false
127
+ }
128
+
129
+ /**
130
+ * Enable local access bypass for Homepage widget access
131
+ * Note: This allows unauthenticated API access from Docker network
132
+ */
133
+ async enableLocalAccessBypass(): Promise<boolean> {
134
+ try {
135
+ const response = await fetch(`${this.baseUrl}/api/settings/general`, {
136
+ method: "POST",
137
+ headers: this.getHeaders(),
138
+ body: JSON.stringify({ local_access_bypass: true }),
139
+ signal: AbortSignal.timeout(10000),
140
+ })
141
+
142
+ if (response.ok) {
143
+ debugLog("HuntarrApi", "Enabled local_access_bypass for Homepage widget")
144
+ return true
145
+ }
146
+
147
+ debugLog("HuntarrApi", `Failed to enable local_access_bypass: ${response.status}`)
148
+ } catch (error) {
149
+ debugLog("HuntarrApi", `Error enabling local_access_bypass: ${error}`)
150
+ }
151
+ return false
152
+ }
153
+
154
+ /**
155
+ * Complete setup by saving progress then clearing
156
+ */
157
+ async completeSetup(username: string): Promise<boolean> {
158
+ try {
159
+ // First save progress with all steps completed
160
+ const progress = {
161
+ current_step: 6,
162
+ completed_steps: [1, 2, 3, 4, 5],
163
+ account_created: true,
164
+ two_factor_enabled: false,
165
+ plex_setup_done: false,
166
+ auth_mode_selected: false,
167
+ recovery_key_generated: true,
168
+ username,
169
+ timestamp: new Date().toISOString(),
170
+ }
171
+
172
+ const saveResponse = await fetch(`${this.baseUrl}/api/setup/progress`, {
173
+ method: "POST",
174
+ headers: this.getHeaders(),
175
+ body: JSON.stringify({ progress }),
176
+ signal: AbortSignal.timeout(5000),
177
+ })
178
+
179
+ if (!saveResponse.ok) {
180
+ debugLog("HuntarrApi", `Failed to save setup progress: ${saveResponse.status}`)
181
+ return false
182
+ }
183
+ debugLog("HuntarrApi", "Saved setup progress with all steps completed")
184
+
185
+ // Then clear the setup progress
186
+ const clearResponse = await fetch(`${this.baseUrl}/api/setup/clear`, {
187
+ method: "POST",
188
+ headers: this.getHeaders(),
189
+ signal: AbortSignal.timeout(5000),
190
+ })
191
+
192
+ if (clearResponse.ok) {
193
+ debugLog("HuntarrApi", "Cleared setup progress")
194
+ return true
195
+ }
196
+
197
+ debugLog("HuntarrApi", `Failed to clear setup: ${clearResponse.status}`)
198
+ } catch (error) {
199
+ debugLog("HuntarrApi", `Complete setup error: ${error}`)
200
+ }
201
+ return false
202
+ }
203
+
204
+ /**
205
+ * Login to Huntarr and get session cookie
206
+ */
207
+ async login(username: string, password: string): Promise<boolean> {
208
+ try {
209
+ const response = await fetch(`${this.baseUrl}/login`, {
210
+ method: "POST",
211
+ headers: { "Content-Type": "application/json" },
212
+ body: JSON.stringify({ username, password }),
213
+ signal: AbortSignal.timeout(10000),
214
+ })
215
+
216
+ if (response.ok) {
217
+ const setCookie = response.headers.get("set-cookie")
218
+ if (setCookie) {
219
+ const match = setCookie.match(/huntarr_session=([^;]+)/)
220
+ if (match) {
221
+ this.sessionCookie = match[1]
222
+ debugLog("HuntarrApi", "Logged in and got session cookie")
223
+ return true
224
+ }
225
+ }
226
+ // Even without cookie extraction, check response
227
+ const data = await response.json()
228
+ return data.success === true
229
+ }
230
+
231
+ debugLog("HuntarrApi", `Login failed: ${response.status}`)
232
+ } catch (error) {
233
+ debugLog("HuntarrApi", `Login error: ${error}`)
234
+ }
235
+ return false
236
+ }
237
+
238
+ /**
239
+ * Authenticate with Huntarr - creates user if needed, otherwise logs in
240
+ */
241
+ async authenticate(username: string, password: string): Promise<boolean> {
242
+ // First check if user exists
243
+ const exists = await this.userExists()
244
+
245
+ if (!exists) {
246
+ // No user yet - create one (createUser calls enableLocalAccessBypass)
247
+ debugLog("HuntarrApi", "No user exists, creating...")
248
+ return await this.createUser(username, password)
249
+ }
250
+
251
+ // User exists - try to login
252
+ debugLog("HuntarrApi", "User exists, logging in...")
253
+ const loggedIn = await this.login(username, password)
254
+
255
+ if (loggedIn) {
256
+ // Enable local bypass for Homepage widget access
257
+ await this.enableLocalAccessBypass()
258
+ }
259
+
260
+ return loggedIn
261
+ }
262
+
263
+ /**
264
+ * Check if Huntarr is reachable
265
+ */
266
+ async isHealthy(): Promise<boolean> {
267
+ try {
268
+ const response = await fetch(`${this.baseUrl}/health`, {
269
+ method: "GET",
270
+ signal: AbortSignal.timeout(5000),
271
+ })
272
+ debugLog("HuntarrApi", `Health check: ${response.status}`)
273
+ return response.ok
274
+ } catch (error) {
275
+ debugLog("HuntarrApi", `Health check failed: ${error}`)
276
+ return false
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Check if Huntarr has any *arr apps configured
282
+ */
283
+ async isInitialized(): Promise<boolean> {
284
+ // Huntarr is considered initialized if we can reach it
285
+ // Actual instance configuration happens via setup()
286
+ return this.isHealthy()
287
+ }
288
+
289
+ /**
290
+ * Get Huntarr version
291
+ * Uses the /api/version endpoint (unauthenticated)
292
+ */
293
+ async getVersion(): Promise<string | null> {
294
+ try {
295
+ const response = await fetch(`${this.baseUrl}/api/version`, {
296
+ method: "GET",
297
+ signal: AbortSignal.timeout(5000),
298
+ })
299
+
300
+ if (response.ok) {
301
+ const data = (await response.json()) as HuntarrVersion
302
+ debugLog("HuntarrApi", `Version: ${data.version}`)
303
+ return data.version
304
+ }
305
+
306
+ debugLog("HuntarrApi", `Failed to get version: ${response.status}`)
307
+ } catch (error) {
308
+ debugLog("HuntarrApi", `Error getting version: ${error}`)
309
+ }
310
+ return null
311
+ }
312
+
313
+ /**
314
+ * Test connection to an *arr app via Huntarr
315
+ */
316
+ async testConnection(appType: HuntarrAppType, apiUrl: string, apiKey: string): Promise<TestConnectionResult> {
317
+ try {
318
+ const response = await fetch(`${this.baseUrl}/api/${appType}/test-connection`, {
319
+ method: "POST",
320
+ headers: {
321
+ "Content-Type": "application/json",
322
+ },
323
+ body: JSON.stringify({
324
+ api_url: apiUrl,
325
+ api_key: apiKey,
326
+ api_timeout: 30,
327
+ }),
328
+ signal: AbortSignal.timeout(35000),
329
+ })
330
+
331
+ const data = (await response.json()) as TestConnectionResult
332
+ debugLog("HuntarrApi", `Test ${appType} connection: ${data.success} - ${data.message}`)
333
+ return data
334
+ } catch (error) {
335
+ debugLog("HuntarrApi", `Test connection error for ${appType}: ${error}`)
336
+ return { success: false, message: `${error}` }
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Get current settings for an *arr app from Huntarr
342
+ */
343
+ async getSettings(appType: HuntarrAppType): Promise<HuntarrAppSettings | null> {
344
+ try {
345
+ const response = await fetch(`${this.baseUrl}/api/settings/${appType}`, {
346
+ method: "GET",
347
+ headers: this.getHeaders(),
348
+ signal: AbortSignal.timeout(10000),
349
+ })
350
+
351
+ if (response.ok) {
352
+ const data = (await response.json()) as HuntarrAppSettings
353
+ debugLog("HuntarrApi", `Got ${appType} settings: ${data.instances?.length ?? 0} instances`)
354
+ return data
355
+ }
356
+
357
+ debugLog("HuntarrApi", `Failed to get ${appType} settings: ${response.status}`)
358
+ } catch (error) {
359
+ debugLog("HuntarrApi", `Error getting ${appType} settings: ${error}`)
360
+ }
361
+ return null
362
+ }
363
+
364
+ /**
365
+ * Save settings for an *arr app to Huntarr
366
+ * This adds/updates the app instance configuration
367
+ */
368
+ async saveSettings(appType: HuntarrAppType, settings: HuntarrAppSettings): Promise<boolean> {
369
+ try {
370
+ const response = await fetch(`${this.baseUrl}/api/settings/${appType}`, {
371
+ method: "POST",
372
+ headers: this.getHeaders(),
373
+ body: JSON.stringify(settings),
374
+ signal: AbortSignal.timeout(10000),
375
+ })
376
+
377
+ if (response.ok) {
378
+ debugLog("HuntarrApi", `Saved ${appType} settings successfully`)
379
+ return true
380
+ }
381
+
382
+ debugLog("HuntarrApi", `Failed to save ${appType} settings: ${response.status}`)
383
+ } catch (error) {
384
+ debugLog("HuntarrApi", `Error saving ${appType} settings: ${error}`)
385
+ }
386
+ return false
387
+ }
388
+
389
+ /**
390
+ * Add an *arr app instance to Huntarr
391
+ * Gets current settings, adds new instance if not exists, saves
392
+ */
393
+ async addArrInstance(appType: HuntarrAppType, name: string, apiUrl: string, apiKey: string): Promise<boolean> {
394
+ try {
395
+ // Get current settings (preserve all other app settings)
396
+ const settings = await this.getSettings(appType)
397
+
398
+ if (!settings) {
399
+ // If no settings exist, create minimal structure
400
+ return await this.saveSettings(appType, {
401
+ instances: [{ name, api_url: apiUrl, api_key: apiKey, enabled: true }],
402
+ })
403
+ }
404
+
405
+ // Initialize instances array if needed
406
+ if (!settings.instances) settings.instances = []
407
+
408
+ // Check if instance with same URL already exists and is configured
409
+ const existingByUrl = settings.instances.find((i) => i.api_url === apiUrl && i.api_key)
410
+ if (existingByUrl) {
411
+ debugLog("HuntarrApi", `Instance for ${apiUrl} already configured in ${appType}`)
412
+ return true
413
+ }
414
+
415
+ // Check if there's a default/empty instance we can update
416
+ const emptyInstance = settings.instances.find((i) => !i.api_url || !i.api_key)
417
+ if (emptyInstance) {
418
+ // Update the existing empty instance
419
+ emptyInstance.name = name
420
+ emptyInstance.api_url = apiUrl
421
+ emptyInstance.api_key = apiKey
422
+ emptyInstance.enabled = true
423
+ debugLog("HuntarrApi", `Updated default instance in ${appType}`)
424
+ } else {
425
+ // Add new instance
426
+ settings.instances.push({
427
+ name,
428
+ api_url: apiUrl,
429
+ api_key: apiKey,
430
+ enabled: true,
431
+ })
432
+ debugLog("HuntarrApi", `Added new instance to ${appType}`)
433
+ }
434
+
435
+ // Save updated settings (preserves all other app settings)
436
+ return await this.saveSettings(appType, settings)
437
+ } catch (error) {
438
+ debugLog("HuntarrApi", `Error adding ${appType} instance: ${error}`)
439
+ return false
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Run auto-setup process for Huntarr
445
+ * Tests connections to all enabled *arr apps
446
+ */
447
+ async setup(options: AutoSetupOptions): Promise<AutoSetupResult> {
448
+ const { env } = options
449
+ const results: Array<{ app: string; success: boolean; message?: string }> = []
450
+
451
+ try {
452
+ // Check if Huntarr is reachable
453
+ const healthy = await this.isHealthy()
454
+ if (!healthy) {
455
+ return { success: false, message: "Huntarr not reachable" }
456
+ }
457
+
458
+ // Get Huntarr version for logging
459
+ const version = await this.getVersion()
460
+ debugLog("HuntarrApi", `Huntarr version: ${version}`)
461
+
462
+ // Import registry to get app ports
463
+ const { getApp } = await import("../apps/registry")
464
+
465
+ // Test connections for each *arr app that has an API key
466
+ // Note: Use setupEasiarrApps() for filtering by enabled apps
467
+ for (const appType of HUNTARR_APP_TYPES) {
468
+ const apiKeyEnvName = `API_KEY_${appType.toUpperCase()}`
469
+ const apiKey = env[apiKeyEnvName]
470
+
471
+ if (!apiKey) {
472
+ debugLog("HuntarrApi", `Skipping ${appType} - no API key in env`)
473
+ continue
474
+ }
475
+
476
+ // Get port from registry
477
+ const appDef = getApp(appType)
478
+ const port = appDef?.defaultPort ?? 8989
479
+ const apiUrl = `http://${appType}:${port}`
480
+ debugLog("HuntarrApi", `Testing ${appType} at ${apiUrl}`)
481
+
482
+ const result = await this.testConnection(appType, apiUrl, apiKey)
483
+ results.push({
484
+ app: appType,
485
+ success: result.success,
486
+ message: result.message,
487
+ })
488
+ }
489
+
490
+ // Summarize results
491
+ const successCount = results.filter((r) => r.success).length
492
+ const failCount = results.filter((r) => !r.success).length
493
+
494
+ if (results.length === 0) {
495
+ return {
496
+ success: true,
497
+ message: "No *arr apps configured with API keys",
498
+ data: { version },
499
+ }
500
+ }
501
+
502
+ if (failCount === 0) {
503
+ return {
504
+ success: true,
505
+ message: `All ${successCount} *arr connections verified`,
506
+ data: { version, results },
507
+ }
508
+ }
509
+
510
+ return {
511
+ success: successCount > 0,
512
+ message: `${successCount} succeeded, ${failCount} failed. Configure failed apps in Huntarr web UI.`,
513
+ data: { version, results },
514
+ }
515
+ } catch (error) {
516
+ return { success: false, message: `${error}` }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Auto-configure Huntarr with enabled *arr apps
522
+ * Actually adds/updates app instances in Huntarr settings
523
+ */
524
+ async setupEasiarrApps(
525
+ apps: AppConfig[],
526
+ env: Record<string, string>
527
+ ): Promise<{
528
+ added: number
529
+ skipped: number
530
+ results: Array<{ app: string; success: boolean; message?: string }>
531
+ }> {
532
+ const results: Array<{ app: string; success: boolean; message?: string }> = []
533
+ const { getApp } = await import("../apps/registry")
534
+
535
+ for (const appConfig of apps) {
536
+ if (!appConfig.enabled) continue
537
+
538
+ // Only process *arr apps that Huntarr supports
539
+ if (!HUNTARR_APP_TYPES.includes(appConfig.id as HuntarrAppType)) continue
540
+
541
+ const appType = appConfig.id as HuntarrAppType
542
+ const apiKey = env[`API_KEY_${appType.toUpperCase()}`]
543
+
544
+ if (!apiKey) {
545
+ debugLog("HuntarrApi", `Skipping ${appType} - no API key`)
546
+ continue
547
+ }
548
+
549
+ // Get port from registry
550
+ const appDef = getApp(appType)
551
+ const port = appDef?.defaultPort ?? 8989
552
+ const apiUrl = `http://${appType}:${port}`
553
+ const instanceName = appDef?.name ?? appType
554
+
555
+ debugLog("HuntarrApi", `Adding ${appType} instance to Huntarr`)
556
+ const added = await this.addArrInstance(appType, instanceName, apiUrl, apiKey)
557
+ results.push({
558
+ app: appType,
559
+ success: added,
560
+ message: added ? "Added to Huntarr" : "Failed to add",
561
+ })
562
+ }
563
+
564
+ // Also configure Prowlarr (different structure - not instance-based)
565
+ const prowlarrConfig = apps.find((a) => a.id === "prowlarr" && a.enabled)
566
+ if (prowlarrConfig) {
567
+ const prowlarrApiKey = env["API_KEY_PROWLARR"]
568
+ if (prowlarrApiKey) {
569
+ const prowlarrDef = getApp("prowlarr")
570
+ const prowlarrPort = prowlarrConfig.port || prowlarrDef?.defaultPort || 9696
571
+ const prowlarrUrl = `http://prowlarr:${prowlarrPort}`
572
+
573
+ debugLog("HuntarrApi", `Configuring Prowlarr in Huntarr`)
574
+ const configured = await this.configureProwlarr(prowlarrUrl, prowlarrApiKey)
575
+ results.push({
576
+ app: "prowlarr",
577
+ success: configured,
578
+ message: configured ? "Configured in Huntarr" : "Failed to configure",
579
+ })
580
+ }
581
+ }
582
+
583
+ return {
584
+ added: results.filter((r) => r.success).length,
585
+ skipped: results.filter((r) => !r.success).length,
586
+ results,
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Configure Prowlarr in Huntarr (different structure - not instance-based)
592
+ */
593
+ async configureProwlarr(apiUrl: string, apiKey: string): Promise<boolean> {
594
+ try {
595
+ // Prowlarr settings are simple: api_url, api_key, name, enabled
596
+ const settings = {
597
+ api_url: apiUrl,
598
+ api_key: apiKey,
599
+ name: "Prowlarr",
600
+ enabled: true,
601
+ }
602
+
603
+ const response = await fetch(`${this.baseUrl}/api/settings/prowlarr`, {
604
+ method: "POST",
605
+ headers: this.getHeaders(),
606
+ body: JSON.stringify(settings),
607
+ signal: AbortSignal.timeout(10000),
608
+ })
609
+
610
+ if (response.ok) {
611
+ debugLog("HuntarrApi", "Prowlarr configured in Huntarr")
612
+ return true
613
+ }
614
+
615
+ const errorBody = await response.text()
616
+ debugLog("HuntarrApi", `Failed to configure Prowlarr: ${response.status} - ${errorBody}`)
617
+ } catch (error) {
618
+ debugLog("HuntarrApi", `Error configuring Prowlarr: ${error}`)
619
+ }
620
+ return false
621
+ }
622
+ }