@muhammedaksam/easiarr 1.1.8 → 1.2.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,129 @@
1
+ /**
2
+ * Caddy Configuration Generator
3
+ * Generates Caddyfile for automatic HTTPS reverse proxy
4
+ */
5
+
6
+ import { mkdir, writeFile } from "node:fs/promises"
7
+ import { existsSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import type { EasiarrConfig } from "../config/schema"
10
+ import { getApp } from "../apps/registry"
11
+ import { debugLog } from "../utils/debug"
12
+
13
+ /**
14
+ * Generate Caddyfile content for enabled apps
15
+ */
16
+ export function generateCaddyfile(config: EasiarrConfig): string {
17
+ const domain = config.caddy?.domain || "localhost"
18
+ const email = config.caddy?.email
19
+
20
+ let caddyfile = `# Caddyfile
21
+ # Generated by easiarr - https://github.com/muhammedaksam/easiarr
22
+ #
23
+ # Caddy provides automatic HTTPS via Let's Encrypt.
24
+ # For local dev, use 'localhost' or ':80' as domain.
25
+ #
26
+ # To manually reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile
27
+
28
+ `
29
+
30
+ // Global options block (if email is set for ACME)
31
+ if (email) {
32
+ caddyfile += `{
33
+ email ${email}
34
+ }
35
+
36
+ `
37
+ }
38
+
39
+ // Get enabled apps that should be proxied
40
+ const proxyApps = config.apps.filter((app) => {
41
+ if (!app.enabled) return false
42
+ // Skip caddy itself and apps without ports
43
+ if (app.id === "caddy" || app.id === "cloudflared") return false
44
+ const def = getApp(app.id)
45
+ if (!def || def.defaultPort === 0) return false
46
+ return true
47
+ })
48
+
49
+ // Generate reverse proxy blocks for each app
50
+ for (const app of proxyApps) {
51
+ const def = getApp(app.id)
52
+ if (!def) continue
53
+
54
+ const port = app.port ?? def.defaultPort
55
+ const subdomain = app.id
56
+ const internalPort = def.internalPort ?? port
57
+
58
+ // Format: subdomain.domain.com
59
+ const host = domain === "localhost" ? `${subdomain}.localhost` : `${subdomain}.${domain}`
60
+
61
+ caddyfile += `# ${def.name}
62
+ ${host} {
63
+ reverse_proxy ${app.id}:${internalPort}
64
+ }
65
+
66
+ `
67
+ }
68
+
69
+ return caddyfile
70
+ }
71
+
72
+ /**
73
+ * Save Caddyfile to the config directory
74
+ */
75
+ export async function saveCaddyConfig(config: EasiarrConfig): Promise<string> {
76
+ // Check if Caddy is enabled
77
+ const caddyApp = config.apps.find((a) => a.id === "caddy" && a.enabled)
78
+ if (!caddyApp) {
79
+ debugLog("Caddy", "Caddy not enabled, skipping config generation")
80
+ return ""
81
+ }
82
+
83
+ const caddyDir = join(config.rootDir, "config", "caddy")
84
+ const dataDir = join(caddyDir, "data")
85
+ const configDir = join(caddyDir, "config")
86
+
87
+ debugLog("Caddy", `Generating Caddyfile in ${caddyDir}`)
88
+
89
+ try {
90
+ // Create directories if they don't exist
91
+ if (!existsSync(caddyDir)) {
92
+ await mkdir(caddyDir, { recursive: true })
93
+ }
94
+ if (!existsSync(dataDir)) {
95
+ await mkdir(dataDir, { recursive: true })
96
+ }
97
+ if (!existsSync(configDir)) {
98
+ await mkdir(configDir, { recursive: true })
99
+ }
100
+
101
+ // Generate Caddyfile
102
+ const caddyfileContent = generateCaddyfile(config)
103
+ const caddyfilePath = join(caddyDir, "Caddyfile")
104
+
105
+ // Always overwrite Caddyfile to reflect current app config
106
+ await writeFile(caddyfilePath, caddyfileContent, "utf-8")
107
+ debugLog("Caddy", `Saved Caddyfile to ${caddyfilePath}`)
108
+
109
+ return caddyfilePath
110
+ } catch (error) {
111
+ const err = error as NodeJS.ErrnoException
112
+ if (err.code === "EACCES") {
113
+ console.warn(
114
+ `[WARN] Cannot write Caddy config files (permission denied). ` +
115
+ `Fix with: sudo chown -R $(id -u):$(id -g) ${caddyDir}`
116
+ )
117
+ } else {
118
+ throw error
119
+ }
120
+ return ""
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Get the path to the Caddyfile
126
+ */
127
+ export function getCaddyfilePath(config: EasiarrConfig): string {
128
+ return join(config.rootDir, "config", "caddy", "Caddyfile")
129
+ }
@@ -10,6 +10,7 @@ import { getApp } from "../apps/registry"
10
10
  import { generateServiceYaml } from "./templates"
11
11
  import { updateEnv, getLocalIp } from "../utils/env"
12
12
  import { saveTraefikConfig } from "./traefik-config"
13
+ import { saveCaddyConfig } from "./caddy-config"
13
14
  import { debugLog } from "../utils/debug"
14
15
 
15
16
  export interface ComposeService {
@@ -111,6 +112,11 @@ function buildService(appDef: ReturnType<typeof getApp>, appConfig: AppConfig, c
111
112
  // Use ${ROOT_DIR} for volumes
112
113
  const volumes = [...appDef.volumes("${ROOT_DIR}"), ...(appConfig.customVolumes ?? [])]
113
114
 
115
+ // Add log volume mount if logMount is enabled and app has logVolume defined
116
+ if (config.logMount && appDef.logVolume) {
117
+ volumes.push(`\${ROOT_DIR}/logs/${appDef.id}:${appDef.logVolume}`)
118
+ }
119
+
114
120
  // Build environment
115
121
  const environment: Record<string, string | number> = {
116
122
  TZ: "${TIMEZONE}",
@@ -181,7 +187,7 @@ function buildService(appDef: ReturnType<typeof getApp>, appConfig: AppConfig, c
181
187
  }
182
188
  }
183
189
 
184
- if (config.traefik?.enabled && appDef.id !== "plex" && appDef.id !== "cloudflared") {
190
+ if (config.traefik?.enabled && appDef.id !== "plex" && appDef.id !== "cloudflared" && appDef.defaultPort !== 0) {
185
191
  if (appDef.id === "traefik") {
186
192
  // Special labels for Traefik dashboard (accessible via traefik.domain on port 8080)
187
193
  service.labels = generateTraefikLabels("traefik", 8080, config.traefik)
@@ -237,6 +243,9 @@ export async function saveCompose(config: EasiarrConfig): Promise<string> {
237
243
  // Generate Traefik config files if Traefik is enabled
238
244
  await saveTraefikConfig(config)
239
245
 
246
+ // Generate Caddy config files if Caddy is enabled
247
+ await saveCaddyConfig(config)
248
+
240
249
  return path
241
250
  }
242
251
 
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Recyclarr Configuration Generator
3
+ * Generates recyclarr.yml for automatic TRaSH Guides profile sync
4
+ */
5
+
6
+ import { mkdir, writeFile } from "node:fs/promises"
7
+ import { join } from "node:path"
8
+ import YAML from "yaml"
9
+ import type { EasiarrConfig, AppId } from "./schema"
10
+ import { readEnv } from "../utils/env"
11
+ import { debugLog } from "../utils/debug"
12
+
13
+ /**
14
+ * Get API key for an app from the .env file
15
+ */
16
+ async function getAppApiKey(appId: AppId): Promise<string | null> {
17
+ const env = await readEnv()
18
+ const envKey = `API_KEY_${appId.toUpperCase()}`
19
+ return env[envKey] ?? null
20
+ }
21
+
22
+ // TRaSH Guide Custom Format IDs for common unwanted content
23
+ const UNWANTED_CFS = {
24
+ radarr: [
25
+ "ed38b889b31be83fda192888e2286d83", // BR-DISK
26
+ "90a6f9a284dff5103f6346090e6280c8", // LQ
27
+ "e204b80c87be9497a8a6eaff48f72905", // LQ (Release Title)
28
+ "dc98083864ea246d05a42df0d05f81cc", // x265 (HD)
29
+ "0a3f082873eb454bde444150b70253cc", // Extras
30
+ ],
31
+ sonarr: [
32
+ "85c61753df5da1fb2aab6f2a47426b09", // BR-DISK
33
+ "9c11cd3f07101cdba90a2d81cf0e56b4", // LQ
34
+ "e2315f990da2e2cbfc9fa5b7a6fcfe48", // LQ (Release Title)
35
+ "47435ece6b99a0b477caf360e79ba0bb", // x265 (HD)
36
+ "fbcb31d8dabd2a319072b84fc0b7249c", // Extras
37
+ ],
38
+ }
39
+
40
+ // TRaSH Guide Quality Definition types
41
+ const QUALITY_DEFINITIONS = {
42
+ radarr: "movie",
43
+ sonarr: "series",
44
+ }
45
+
46
+ // Default quality profiles to assign scores to
47
+ const DEFAULT_PROFILES = {
48
+ radarr: ["HD", "HD-1080p", "Ultra-HD", "Any"],
49
+ sonarr: ["WEB-1080p", "WEB-DL (1080p)", "HD-1080p", "Any"],
50
+ }
51
+
52
+ interface RecyclarrInstance {
53
+ base_url: string
54
+ api_key: string
55
+ quality_definition?: {
56
+ type: string
57
+ preferred_ratio?: number
58
+ }
59
+ custom_formats?: Array<{
60
+ trash_ids: string[]
61
+ quality_profiles: Array<{
62
+ name: string
63
+ }>
64
+ }>
65
+ }
66
+
67
+ interface RecyclarrConfig {
68
+ radarr?: Record<string, RecyclarrInstance>
69
+ sonarr?: Record<string, RecyclarrInstance>
70
+ }
71
+
72
+ /**
73
+ * Generate recyclarr.yml configuration for enabled *arr apps
74
+ */
75
+ export async function generateRecyclarrConfig(config: EasiarrConfig): Promise<string> {
76
+ debugLog("Recyclarr", "Generating recyclarr.yml configuration")
77
+
78
+ const recyclarrConfig: RecyclarrConfig = {}
79
+
80
+ // Check for enabled Radarr
81
+ const radarrApp = config.apps.find((a) => a.id === "radarr" && a.enabled)
82
+ if (radarrApp) {
83
+ const apiKey = await getAppApiKey("radarr")
84
+ if (apiKey) {
85
+ const port = radarrApp.port ?? 7878
86
+ recyclarrConfig.radarr = {
87
+ radarr_main: {
88
+ base_url: `http://radarr:${port}`,
89
+ api_key: apiKey,
90
+ quality_definition: {
91
+ type: QUALITY_DEFINITIONS.radarr,
92
+ preferred_ratio: 0.5,
93
+ },
94
+ custom_formats: [
95
+ {
96
+ trash_ids: UNWANTED_CFS.radarr,
97
+ quality_profiles: DEFAULT_PROFILES.radarr.map((name) => ({ name })),
98
+ },
99
+ ],
100
+ },
101
+ }
102
+ debugLog("Recyclarr", "Added Radarr configuration")
103
+ } else {
104
+ debugLog("Recyclarr", "Radarr enabled but no API key found - skipping")
105
+ }
106
+ }
107
+
108
+ // Check for enabled Sonarr
109
+ const sonarrApp = config.apps.find((a) => a.id === "sonarr" && a.enabled)
110
+ if (sonarrApp) {
111
+ const apiKey = await getAppApiKey("sonarr")
112
+ if (apiKey) {
113
+ const port = sonarrApp.port ?? 8989
114
+ recyclarrConfig.sonarr = {
115
+ sonarr_main: {
116
+ base_url: `http://sonarr:${port}`,
117
+ api_key: apiKey,
118
+ quality_definition: {
119
+ type: QUALITY_DEFINITIONS.sonarr,
120
+ },
121
+ custom_formats: [
122
+ {
123
+ trash_ids: UNWANTED_CFS.sonarr,
124
+ quality_profiles: DEFAULT_PROFILES.sonarr.map((name) => ({ name })),
125
+ },
126
+ ],
127
+ },
128
+ }
129
+ debugLog("Recyclarr", "Added Sonarr configuration")
130
+ } else {
131
+ debugLog("Recyclarr", "Sonarr enabled but no API key found - skipping")
132
+ }
133
+ }
134
+
135
+ // Generate YAML
136
+ const yamlContent = YAML.stringify(recyclarrConfig, {
137
+ indent: 2,
138
+ lineWidth: 0,
139
+ })
140
+
141
+ // Add header comment
142
+ const header = `# Recyclarr Configuration
143
+ # Generated by easiarr - https://github.com/muhammedaksam/easiarr
144
+ #
145
+ # This configuration syncs TRaSH Guides profiles to your *arr apps.
146
+ # Recyclarr runs daily via cron to keep profiles up to date.
147
+ #
148
+ # For more options, see: https://recyclarr.dev/reference/configuration/
149
+ #
150
+ # To manually trigger sync: docker compose run --rm recyclarr sync
151
+ #
152
+
153
+ `
154
+
155
+ return header + yamlContent
156
+ }
157
+
158
+ /**
159
+ * Save recyclarr.yml to the config directory
160
+ */
161
+ export async function saveRecyclarrConfig(config: EasiarrConfig): Promise<string> {
162
+ const recyclarrDir = join(config.rootDir, "config", "recyclarr")
163
+ await mkdir(recyclarrDir, { recursive: true })
164
+
165
+ const configContent = await generateRecyclarrConfig(config)
166
+ const configPath = join(recyclarrDir, "recyclarr.yml")
167
+
168
+ await writeFile(configPath, configContent, "utf-8")
169
+ debugLog("Recyclarr", `Saved recyclarr.yml to ${configPath}`)
170
+
171
+ return configPath
172
+ }
173
+
174
+ /**
175
+ * Get the path to the recyclarr config file
176
+ */
177
+ export function getRecyclarrConfigPath(config: EasiarrConfig): string {
178
+ return join(config.rootDir, "config", "recyclarr", "recyclarr.yml")
179
+ }
@@ -14,13 +14,27 @@ export interface EasiarrConfig {
14
14
  umask: string
15
15
  apps: AppConfig[]
16
16
  network?: NetworkConfig
17
+ /** Which reverse proxy to use: traefik, caddy, or none */
18
+ reverseProxy?: "traefik" | "caddy" | "none"
17
19
  traefik?: TraefikConfig
20
+ caddy?: CaddyConfig
18
21
  vpn?: VpnConfig
19
22
  monitor?: MonitorConfig
23
+ /** Bind-mount container logs to host for external log aggregation */
24
+ logMount?: boolean
20
25
  createdAt: string
21
26
  updatedAt: string
22
27
  }
23
28
 
29
+ export type ReverseProxyType = "traefik" | "caddy" | "none"
30
+
31
+ export interface CaddyConfig {
32
+ enabled: boolean
33
+ domain: string
34
+ /** Email for ACME/Let's Encrypt */
35
+ email?: string
36
+ }
37
+
24
38
  export type VpnMode = "full" | "mini" | "none"
25
39
 
26
40
  export interface VpnConfig {
@@ -129,6 +143,8 @@ export type AppId =
129
143
  | "guacd"
130
144
  | "ddns-updater"
131
145
  | "easiarr"
146
+ | "recyclarr"
147
+ | "profilarr"
132
148
  // VPN
133
149
  | "gluetun"
134
150
  // Monitoring & Infra
@@ -141,6 +157,7 @@ export type AppId =
141
157
  // Reverse Proxy
142
158
  | "traefik"
143
159
  | "traefik-certs-dumper"
160
+ | "caddy"
144
161
  | "cloudflared"
145
162
  | "crowdsec"
146
163
  // Network/VPN
@@ -212,6 +229,8 @@ export interface AppDefinition {
212
229
  autoSetup?: AutoSetupCapability
213
230
  /** Use Docker's user: directive instead of PUID/PGID env vars (e.g., slskd) */
214
231
  useDockerUser?: boolean
232
+ /** Path inside container where logs are stored (e.g., "/config/logs"), for bind-mounting */
233
+ logVolume?: string
215
234
  }
216
235
 
217
236
  /** Auto-setup capability for an app */
@@ -45,6 +45,22 @@ export async function composeDown(): Promise<{
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * Run a one-off container with docker compose run --rm
50
+ */
51
+ export async function composeRun(service: string, command: string): Promise<{ success: boolean; output: string }> {
52
+ try {
53
+ const composePath = getComposePath()
54
+ debugLog("Docker", `compose run --rm ${service} ${command}`)
55
+ const result = await $`docker compose -f ${composePath} run --rm ${service} ${command}`.text()
56
+ debugLog("Docker", `compose run success`)
57
+ return { success: true, output: result }
58
+ } catch (error) {
59
+ debugLog("Docker", `compose run failed: ${error}`)
60
+ return { success: false, output: String(error) }
61
+ }
62
+ }
63
+
48
64
  export async function composeRestart(service?: string): Promise<{ success: boolean; output: string }> {
49
65
  try {
50
66
  const composePath = getComposePath()
@@ -1,6 +1,7 @@
1
1
  import { mkdir } from "node:fs/promises"
2
2
  import { join } from "node:path"
3
3
  import type { EasiarrConfig, AppId } from "../config/schema"
4
+ import { getApp } from "../apps/registry"
4
5
 
5
6
  const BASE_DIRS = ["torrents", "usenet", "media"]
6
7
 
@@ -17,9 +18,11 @@ const CONTENT_TYPE_MAP: Partial<Record<AppId, string>> = {
17
18
  export async function ensureDirectoryStructure(config: EasiarrConfig): Promise<void> {
18
19
  try {
19
20
  const dataRoot = join(config.rootDir, "data")
21
+ const configRoot = join(config.rootDir, "config")
20
22
 
21
- // Create base data directory
23
+ // Create base directories
22
24
  await mkdir(dataRoot, { recursive: true })
25
+ await mkdir(configRoot, { recursive: true })
23
26
 
24
27
  // 1. Create Base Directories (torrents, usenet, media)
25
28
  for (const dir of BASE_DIRS) {
@@ -74,6 +77,43 @@ export async function ensureDirectoryStructure(config: EasiarrConfig): Promise<v
74
77
  await mkdir(join(dataRoot, "media", "audiobooks"), { recursive: true })
75
78
  await mkdir(join(dataRoot, "media", "podcasts"), { recursive: true })
76
79
  }
80
+
81
+ // 4. Create config directories for enabled apps
82
+ // Dynamically check each app's volume definitions to see if it needs a config dir
83
+ for (const appConfig of config.apps) {
84
+ if (appConfig.enabled) {
85
+ const appDef = getApp(appConfig.id)
86
+ if (appDef) {
87
+ // Check if app has volume that maps to config dir
88
+ const volumes = appDef.volumes("$ROOT")
89
+ const hasConfigVolume = volumes.some((v) => v.includes("/config/"))
90
+ if (hasConfigVolume) {
91
+ await mkdir(join(configRoot, appConfig.id), { recursive: true })
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ // Special: Traefik needs letsencrypt subdirectory
98
+ if (enabledApps.has("traefik")) {
99
+ await mkdir(join(configRoot, "traefik", "letsencrypt"), { recursive: true })
100
+ }
101
+
102
+ // 5. Create log directories if logMount is enabled
103
+ if (config.logMount) {
104
+ const logsRoot = join(config.rootDir, "logs")
105
+ await mkdir(logsRoot, { recursive: true })
106
+
107
+ // Create log directory for each enabled app that has logVolume defined
108
+ for (const appConfig of config.apps) {
109
+ if (appConfig.enabled) {
110
+ const appDef = getApp(appConfig.id)
111
+ if (appDef?.logVolume) {
112
+ await mkdir(join(logsRoot, appConfig.id), { recursive: true })
113
+ }
114
+ }
115
+ }
116
+ }
77
117
  } catch (error: unknown) {
78
118
  const err = error as { code?: string; message: string }
79
119
  if (err.code === "EACCES") {
@@ -23,10 +23,12 @@ import { TautulliClient } from "../../api/tautulli-api"
23
23
  import { HomarrClient } from "../../api/homarr-api"
24
24
  import { HeimdallClient } from "../../api/heimdall-api"
25
25
  import { HuntarrClient } from "../../api/huntarr-api"
26
+ import { ProfilarrApiClient } from "../../api/profilarr-api"
26
27
  import { saveConfig } from "../../config"
27
28
  import { saveCompose } from "../../compose"
28
29
  import { generateSlskdConfig, getSlskdConfigPath } from "../../config/slskd-config"
29
30
  import { generateSoularrConfig, getSoularrConfigPath } from "../../config/soularr-config"
31
+ import { saveRecyclarrConfig } from "../../config/recyclarr-config"
30
32
  import { writeFile, mkdir } from "fs/promises"
31
33
  import { dirname } from "path"
32
34
  import { existsSync } from "fs"
@@ -118,6 +120,8 @@ export class FullAutoSetup extends BoxRenderable {
118
120
  { name: "Huntarr", status: "pending" },
119
121
  { name: "Slskd", status: "pending" },
120
122
  { name: "Soularr", status: "pending" },
123
+ { name: "Recyclarr", status: "pending" },
124
+ { name: "Profilarr", status: "pending" },
121
125
  { name: "Cloudflare Tunnel", status: "pending" },
122
126
  ]
123
127
  }
@@ -215,7 +219,13 @@ export class FullAutoSetup extends BoxRenderable {
215
219
  // Step 20: Soularr (Lidarr -> Slskd bridge)
216
220
  await this.setupSoularr()
217
221
 
218
- // Step 21: Cloudflare Tunnel
222
+ // Step 21: Recyclarr (TRaSH Guides sync)
223
+ await this.setupRecyclarr()
224
+
225
+ // Step 22: Profilarr (Alternative TRaSH Guides sync)
226
+ await this.setupProfilarr()
227
+
228
+ // Step 22: Cloudflare Tunnel
219
229
  await this.setupCloudflare()
220
230
 
221
231
  this.isRunning = false
@@ -1384,6 +1394,118 @@ export class FullAutoSetup extends BoxRenderable {
1384
1394
  this.refreshContent()
1385
1395
  }
1386
1396
 
1397
+ private async setupRecyclarr(): Promise<void> {
1398
+ this.updateStep("Recyclarr", "running")
1399
+ this.refreshContent()
1400
+
1401
+ const recyclarrConfig = this.config.apps.find((a) => a.id === "recyclarr" && a.enabled)
1402
+ if (!recyclarrConfig) {
1403
+ this.updateStep("Recyclarr", "skipped", "Not enabled")
1404
+ this.refreshContent()
1405
+ return
1406
+ }
1407
+
1408
+ // Check if we have at least one *arr app with API key
1409
+ const radarrApiKey = this.env["API_KEY_RADARR"]
1410
+ const sonarrApiKey = this.env["API_KEY_SONARR"]
1411
+
1412
+ if (!radarrApiKey && !sonarrApiKey) {
1413
+ this.updateStep("Recyclarr", "skipped", "No Radarr/Sonarr API keys")
1414
+ this.refreshContent()
1415
+ return
1416
+ }
1417
+
1418
+ try {
1419
+ const configPath = await saveRecyclarrConfig(this.config)
1420
+ debugLog("FullAutoSetup", `Generated recyclarr.yml at ${configPath}`)
1421
+ this.updateStep("Recyclarr", "success", "Config generated")
1422
+ } catch (e) {
1423
+ this.updateStep("Recyclarr", "error", `${e}`)
1424
+ }
1425
+ this.refreshContent()
1426
+ }
1427
+
1428
+ private async setupProfilarr(): Promise<void> {
1429
+ this.updateStep("Profilarr", "running")
1430
+ this.refreshContent()
1431
+
1432
+ const profilarrConfig = this.config.apps.find((a) => a.id === "profilarr" && a.enabled)
1433
+ if (!profilarrConfig) {
1434
+ this.updateStep("Profilarr", "skipped", "Not enabled")
1435
+ this.refreshContent()
1436
+ return
1437
+ }
1438
+
1439
+ try {
1440
+ const port = profilarrConfig.port || 6868
1441
+ const url = getApplicationUrl("profilarr", port, this.config)
1442
+
1443
+ this.updateStep("Profilarr", "running", "Waiting for Profilarr...")
1444
+ this.refreshContent()
1445
+
1446
+ const client = new ProfilarrApiClient("localhost", port)
1447
+
1448
+ // Wait for health
1449
+ let healthy = false
1450
+ for (let i = 0; i < 12; i++) {
1451
+ if (await client.isHealthy()) {
1452
+ healthy = true
1453
+ break
1454
+ }
1455
+ await new Promise((resolve) => setTimeout(resolve, 5000))
1456
+ }
1457
+
1458
+ if (!healthy) {
1459
+ throw new Error("Timed out waiting for Profilarr API")
1460
+ }
1461
+
1462
+ this.updateStep("Profilarr", "running", "Configuring...")
1463
+
1464
+ const result = await client.setup({
1465
+ username: this.globalUsername,
1466
+ password: this.globalPassword,
1467
+ env: this.env,
1468
+ })
1469
+
1470
+ if (!result.success) {
1471
+ throw new Error(result.message)
1472
+ }
1473
+
1474
+ // Save API key
1475
+ if (result.envUpdates) {
1476
+ await updateEnv(result.envUpdates)
1477
+ Object.assign(this.env, result.envUpdates)
1478
+ }
1479
+
1480
+ // Configure Radarr/Sonarr connections after auth setup
1481
+ const radarrConfig = this.config.apps.find((a) => a.id === "radarr" && a.enabled)
1482
+ if (radarrConfig && this.env["API_KEY_RADARR"]) {
1483
+ try {
1484
+ await client.configureRadarr("radarr", radarrConfig.port || 7878, this.env["API_KEY_RADARR"])
1485
+ debugLog("FullAutoSetup", "Profilarr: Radarr configured")
1486
+ } catch {
1487
+ /* Radarr config failed */
1488
+ }
1489
+ }
1490
+
1491
+ const sonarrConfig = this.config.apps.find((a) => a.id === "sonarr" && a.enabled)
1492
+ if (sonarrConfig && this.env["API_KEY_SONARR"]) {
1493
+ try {
1494
+ await client.configureSonarr("sonarr", sonarrConfig.port || 8989, this.env["API_KEY_SONARR"])
1495
+ debugLog("FullAutoSetup", "Profilarr: Sonarr configured")
1496
+ } catch {
1497
+ /* Sonarr config failed */
1498
+ }
1499
+ }
1500
+
1501
+ this.updateStep("Profilarr", "success", `Configured - ${url}`)
1502
+ } catch (e) {
1503
+ debugLog("FullAutoSetup", `Profilarr setup error: ${e}`)
1504
+ this.updateStep("Profilarr", "error", `${e}`)
1505
+ }
1506
+ this.refreshContent()
1507
+ }
1508
+
1387
1509
  private updateStep(name: string, status: SetupStep["status"], message?: string): void {
1388
1510
  const step = this.steps.find((s) => s.name === name)
1389
1511
  if (step) {