@muhammedaksam/easiarr 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  > ⚠️ **Work In Progress** - This project is in early experimental development. Features may be incomplete, unstable, or change without notice.
12
12
 
13
- 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.
13
+ TUI tool for generating docker-compose files for the \*arr media ecosystem with 47 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support.
14
14
 
15
15
  A terminal-based wizard that helps you set up Radarr, Sonarr, Prowlarr, and other \*arr applications with Docker Compose, following best practices from [TRaSH Guides](https://trash-guides.info/).
16
16
 
@@ -52,7 +52,7 @@ bun run start
52
52
  - [Bun](https://bun.sh/) >= 1.0
53
53
  - [Docker](https://www.docker.com/) with Docker Compose v2
54
54
 
55
- ## Supported Applications (41 apps across 10 categories)
55
+ ## Supported Applications (47 apps across 10 categories)
56
56
 
57
57
  ### Media Management (Servarr)
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
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",
@@ -52,6 +52,7 @@
52
52
  },
53
53
  "devDependencies": {
54
54
  "@eslint/js": "^9.39.1",
55
+ "@types/bcrypt": "^6.0.0",
55
56
  "@types/bun": "latest",
56
57
  "@types/jest": "^30.0.0",
57
58
  "@types/node": "^25.0.0",
@@ -67,6 +68,7 @@
67
68
  },
68
69
  "dependencies": {
69
70
  "@opentui/core": "^0.1.60",
71
+ "bcrypt": "^6.0.0",
70
72
  "yaml": "^2.8.2"
71
73
  },
72
74
  "engines": {
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Bazarr API Client
3
+ * Handles Bazarr-specific API calls for authentication and settings
4
+ */
5
+
6
+ import { debugLog } from "../utils/debug"
7
+
8
+ /**
9
+ * Bazarr System Settings (partial - auth related fields)
10
+ */
11
+ export interface BazarrAuthSettings {
12
+ auth: {
13
+ type: "None" | "Basic" | "Form"
14
+ username: string
15
+ password: string
16
+ apikey: string
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Bazarr API Client
22
+ * Note: Bazarr uses form data for POST, not JSON!
23
+ */
24
+ export class BazarrApiClient {
25
+ private baseUrl: string
26
+ private apiKey: string | null = null
27
+
28
+ constructor(host: string, port: number) {
29
+ this.baseUrl = `http://${host}:${port}`
30
+ }
31
+
32
+ /**
33
+ * Set API key for authentication
34
+ */
35
+ setApiKey(key: string): void {
36
+ this.apiKey = key
37
+ debugLog("Bazarr", `API key set`)
38
+ }
39
+
40
+ /**
41
+ * Make a GET request to Bazarr API (JSON response)
42
+ */
43
+ private async get<T>(endpoint: string): Promise<T> {
44
+ let url = `${this.baseUrl}/api${endpoint}`
45
+ if (this.apiKey) {
46
+ url = `${url}${url.includes("?") ? "&" : "?"}apikey=${this.apiKey}`
47
+ }
48
+
49
+ debugLog("Bazarr", `GET ${url}`)
50
+
51
+ const response = await fetch(url, {
52
+ method: "GET",
53
+ headers: {
54
+ Accept: "application/json",
55
+ },
56
+ })
57
+
58
+ if (!response.ok) {
59
+ const errorText = await response.text()
60
+ debugLog("Bazarr", `Error ${response.status}: ${errorText}`)
61
+ throw new Error(`Bazarr API error: ${response.status} ${response.statusText}`)
62
+ }
63
+
64
+ const text = await response.text()
65
+ debugLog("Bazarr", `Response: ${text.substring(0, 200)}${text.length > 200 ? "..." : ""}`)
66
+ if (!text) return {} as T
67
+
68
+ return JSON.parse(text) as T
69
+ }
70
+
71
+ /**
72
+ * Make a POST request to Bazarr API using form data (NOT JSON)
73
+ * Bazarr uses request.form, not request.json
74
+ */
75
+ private async postForm(endpoint: string, data: Record<string, string>): Promise<void> {
76
+ let url = `${this.baseUrl}/api${endpoint}`
77
+ if (this.apiKey) {
78
+ url = `${url}${url.includes("?") ? "&" : "?"}apikey=${this.apiKey}`
79
+ }
80
+
81
+ // Convert object to form data
82
+ const formData = new URLSearchParams()
83
+ for (const [key, value] of Object.entries(data)) {
84
+ formData.append(key, value)
85
+ }
86
+
87
+ debugLog("Bazarr", `POST ${url}`)
88
+ debugLog("Bazarr", `Form data: ${formData.toString()}`)
89
+
90
+ const response = await fetch(url, {
91
+ method: "POST",
92
+ headers: {
93
+ "Content-Type": "application/x-www-form-urlencoded",
94
+ },
95
+ body: formData.toString(),
96
+ })
97
+
98
+ if (!response.ok) {
99
+ const errorText = await response.text()
100
+ debugLog("Bazarr", `Error ${response.status}: ${errorText}`)
101
+ throw new Error(`Bazarr API error: ${response.status} ${response.statusText}`)
102
+ }
103
+
104
+ debugLog("Bazarr", `Response status: ${response.status}`)
105
+ }
106
+
107
+ /**
108
+ * Check if Bazarr is healthy and reachable
109
+ */
110
+ async isHealthy(): Promise<boolean> {
111
+ try {
112
+ await this.get("/system/status")
113
+ return true
114
+ } catch {
115
+ return false
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Get current system settings
121
+ */
122
+ async getSettings(): Promise<Record<string, unknown>> {
123
+ return this.get<Record<string, unknown>>("/system/settings")
124
+ }
125
+
126
+ /**
127
+ * Update authentication settings to enable form-based auth
128
+ * Bazarr settings use dot notation in form fields
129
+ */
130
+ async enableFormAuth(username: string, password: string, override = false): Promise<boolean> {
131
+ try {
132
+ // First get current settings to check if auth is already configured
133
+ const currentSettings = await this.getSettings()
134
+ const currentAuth = (currentSettings as { auth?: { type?: string } }).auth
135
+
136
+ // Skip if auth is already configured and override is false
137
+ if (currentAuth?.type && currentAuth.type !== "None" && currentAuth.type !== null && !override) {
138
+ debugLog("Bazarr", `Auth already configured (type: ${currentAuth.type}), skipping`)
139
+ return false
140
+ }
141
+
142
+ debugLog("Bazarr", `Current auth type: ${currentAuth?.type || "None"}`)
143
+ debugLog("Bazarr", `Setting form auth for user: ${username}`)
144
+
145
+ // Bazarr uses dot notation for nested settings in form data
146
+ await this.postForm("/system/settings", {
147
+ "settings-auth-type": "form",
148
+ "settings-auth-username": username,
149
+ "settings-auth-password": password,
150
+ })
151
+
152
+ debugLog("Bazarr", `Form auth enabled for user: ${username}`)
153
+ return true
154
+ } catch (e) {
155
+ debugLog("Bazarr", `Failed to enable form auth: ${e}`)
156
+ throw e
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Get API key from settings
162
+ */
163
+ async getApiKey(): Promise<string | null> {
164
+ try {
165
+ const settings = await this.getSettings()
166
+ const auth = (settings as unknown as BazarrAuthSettings).auth
167
+ return auth?.apikey || null
168
+ } catch {
169
+ return null
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Configure Radarr connection in Bazarr
175
+ */
176
+ async configureRadarr(host: string, port: number, apiKey: string): Promise<boolean> {
177
+ try {
178
+ debugLog("Bazarr", `Configuring Radarr connection: ${host}:${port}`)
179
+
180
+ await this.postForm("/system/settings", {
181
+ "settings-radarr-ip": host,
182
+ "settings-radarr-port": String(port),
183
+ "settings-radarr-apikey": apiKey,
184
+ "settings-radarr-base_url": "",
185
+ "settings-radarr-ssl": "false",
186
+ "settings-general-use_radarr": "true",
187
+ })
188
+
189
+ debugLog("Bazarr", "Radarr connection configured successfully")
190
+ return true
191
+ } catch (e) {
192
+ debugLog("Bazarr", `Failed to configure Radarr: ${e}`)
193
+ throw e
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Configure Sonarr connection in Bazarr
199
+ */
200
+ async configureSonarr(host: string, port: number, apiKey: string): Promise<boolean> {
201
+ try {
202
+ debugLog("Bazarr", `Configuring Sonarr connection: ${host}:${port}`)
203
+
204
+ await this.postForm("/system/settings", {
205
+ "settings-sonarr-ip": host,
206
+ "settings-sonarr-port": String(port),
207
+ "settings-sonarr-apikey": apiKey,
208
+ "settings-sonarr-base_url": "",
209
+ "settings-sonarr-ssl": "false",
210
+ "settings-general-use_sonarr": "true",
211
+ })
212
+
213
+ debugLog("Bazarr", "Sonarr connection configured successfully")
214
+ return true
215
+ } catch (e) {
216
+ debugLog("Bazarr", `Failed to configure Sonarr: ${e}`)
217
+ throw e
218
+ }
219
+ }
220
+ }
@@ -2,6 +2,8 @@
2
2
  * Cloudflare API client for tunnel and DNS management
3
3
  */
4
4
 
5
+ import { debugLog } from "../utils/debug"
6
+
5
7
  const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
6
8
 
7
9
  interface CloudflareResponse<T> {
@@ -49,9 +51,15 @@ export class CloudflareApi {
49
51
 
50
52
  constructor(apiToken: string) {
51
53
  this.apiToken = apiToken
54
+ debugLog("CloudflareAPI", "Client initialized")
52
55
  }
53
56
 
54
57
  private async request<T>(method: string, endpoint: string, body?: unknown): Promise<CloudflareResponse<T>> {
58
+ debugLog("CloudflareAPI", `${method} ${endpoint}`)
59
+ if (body) {
60
+ debugLog("CloudflareAPI", `Request body: ${JSON.stringify(body)}`)
61
+ }
62
+
55
63
  const response = await fetch(`${CLOUDFLARE_API_BASE}${endpoint}`, {
56
64
  method,
57
65
  headers: {
@@ -65,9 +73,11 @@ export class CloudflareApi {
65
73
 
66
74
  if (!data.success) {
67
75
  const errors = data.errors.map((e) => e.message).join(", ")
76
+ debugLog("CloudflareAPI", `Error: ${errors}`)
68
77
  throw new Error(`Cloudflare API error: ${errors}`)
69
78
  }
70
79
 
80
+ debugLog("CloudflareAPI", `Response success: ${data.success}`)
71
81
  return data
72
82
  }
73
83
 
@@ -595,7 +595,8 @@ export const APPS: Record<AppId, AppDefinition> = {
595
595
  pgid: 0,
596
596
  volumes: () => [
597
597
  "${HOME}/.easiarr/config.json:/web/config.json:ro",
598
- "${HOME}/.easiarr/bookmarks.html:/web/bookmarks.html:ro",
598
+ "${HOME}/.easiarr/bookmarks-local.html:/web/bookmarks-local.html:ro",
599
+ "${HOME}/.easiarr/bookmarks-remote.html:/web/bookmarks-remote.html:ro",
599
600
  ],
600
601
  environment: {
601
602
  FOLDER: "/web",
@@ -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 { debugLog } from "../utils/debug"
13
14
 
14
15
  export interface ComposeService {
15
16
  image: string
@@ -31,6 +32,7 @@ export interface ComposeFile {
31
32
  }
32
33
 
33
34
  export function generateCompose(config: EasiarrConfig): string {
35
+ debugLog("ComposeGenerator", `Generating compose for ${config.apps.filter((a) => a.enabled).length} enabled apps`)
34
36
  const services: Record<string, ComposeService> = {}
35
37
 
36
38
  // Track ports to move to Gluetun
@@ -45,6 +47,7 @@ export function generateCompose(config: EasiarrConfig): string {
45
47
  const appDef = getApp(appConfig.id)
46
48
  if (!appDef) continue
47
49
 
50
+ debugLog("ComposeGenerator", `Building service: ${appConfig.id}`)
48
51
  const service = buildService(appDef, appConfig, config)
49
52
  services[appConfig.id] = service
50
53
  }
@@ -6,8 +6,9 @@
6
6
  import { writeFile, mkdir } from "node:fs/promises"
7
7
  import { existsSync } from "node:fs"
8
8
  import { join } from "node:path"
9
- import { createHash } from "node:crypto"
9
+ import { hashSync } from "bcrypt"
10
10
  import type { EasiarrConfig } from "../config/schema"
11
+ import { debugLog } from "../utils/debug"
11
12
 
12
13
  export interface TraefikStaticConfig {
13
14
  entrypoints: {
@@ -97,11 +98,15 @@ http:
97
98
 
98
99
  /**
99
100
  * Generate htpasswd-compatible hash for basic auth
100
- * Uses SHA1 hash in htpasswd format: {SHA}base64(sha1(password))
101
+ * Uses bcrypt for secure password hashing (Traefik supports bcrypt format)
102
+ * Format: $2b$... (bcrypt hash compatible with htpasswd)
101
103
  */
102
104
  function generateHtpasswdHash(password: string): string {
103
- const sha1Hash = createHash("sha1").update(password).digest("base64")
104
- return `{SHA}${sha1Hash}`
105
+ // Use bcrypt with cost factor 10 (standard security level)
106
+ const hash = hashSync(password, 10)
107
+ // Traefik expects bcrypt hashes with $2y$ prefix (PHP-compatible)
108
+ // Node's bcrypt uses $2b$, which Traefik also accepts
109
+ return hash
105
110
  }
106
111
 
107
112
  /**
@@ -110,18 +115,24 @@ function generateHtpasswdHash(password: string): string {
110
115
  export async function saveTraefikConfig(config: EasiarrConfig): Promise<void> {
111
116
  // Check if traefik is enabled
112
117
  const traefikApp = config.apps.find((a) => a.id === "traefik" && a.enabled)
113
- if (!traefikApp) return
118
+ if (!traefikApp) {
119
+ debugLog("Traefik", "Traefik not enabled, skipping config generation")
120
+ return
121
+ }
114
122
 
115
123
  const traefikConfigDir = join(config.rootDir, "config", "traefik")
116
124
  const letsencryptDir = join(traefikConfigDir, "letsencrypt")
125
+ debugLog("Traefik", `Generating config in ${traefikConfigDir}`)
117
126
 
118
127
  try {
119
128
  // Create directories if they don't exist
120
129
  if (!existsSync(traefikConfigDir)) {
121
130
  await mkdir(traefikConfigDir, { recursive: true })
131
+ debugLog("Traefik", `Created directory: ${traefikConfigDir}`)
122
132
  }
123
133
  if (!existsSync(letsencryptDir)) {
124
134
  await mkdir(letsencryptDir, { recursive: true })
135
+ debugLog("Traefik", `Created directory: ${letsencryptDir}`)
125
136
  }
126
137
 
127
138
  // Generate and save static config (traefik.yml)
@@ -111,17 +111,39 @@ export function generateBookmarksHtml(config: EasiarrConfig, useLocalUrls = fals
111
111
 
112
112
  /**
113
113
  * Get the path to the bookmarks file
114
+ * @param type - 'local' for local URLs, 'remote' for Traefik URLs
114
115
  */
115
- export function getBookmarksPath(): string {
116
- return join(homedir(), ".easiarr", "bookmarks.html")
116
+ export function getBookmarksPath(type: "local" | "remote" = "local"): string {
117
+ const filename = type === "remote" ? "bookmarks-remote.html" : "bookmarks-local.html"
118
+ return join(homedir(), ".easiarr", filename)
117
119
  }
118
120
 
119
121
  /**
120
122
  * Save bookmarks HTML file
123
+ * @param type - 'local' for local URLs, 'remote' for Traefik URLs
121
124
  */
122
- export async function saveBookmarks(config: EasiarrConfig, useLocalUrls = false): Promise<string> {
125
+ export async function saveBookmarks(config: EasiarrConfig, type: "local" | "remote" = "local"): Promise<string> {
126
+ const useLocalUrls = type === "local"
123
127
  const html = generateBookmarksHtml(config, useLocalUrls)
124
- const path = getBookmarksPath()
128
+ const path = getBookmarksPath(type)
125
129
  await writeFile(path, html, "utf-8")
126
130
  return path
127
131
  }
132
+
133
+ /**
134
+ * Save all bookmarks files
135
+ * Always saves local bookmarks, and remote bookmarks only if Traefik is enabled
136
+ */
137
+ export async function saveAllBookmarks(config: EasiarrConfig): Promise<string[]> {
138
+ const paths: string[] = []
139
+
140
+ // Always save local bookmarks
141
+ paths.push(await saveBookmarks(config, "local"))
142
+
143
+ // Save remote bookmarks only if Traefik is enabled with a domain
144
+ if (config.traefik?.enabled && config.traefik.domain) {
145
+ paths.push(await saveBookmarks(config, "remote"))
146
+ }
147
+
148
+ return paths
149
+ }
@@ -11,6 +11,7 @@ import type { EasiarrConfig } from "./schema"
11
11
  import { DEFAULT_CONFIG } from "./schema"
12
12
  import { detectTimezone, detectUid, detectGid } from "./defaults"
13
13
  import { VersionInfo } from "../VersionInfo"
14
+ import { debugLog } from "../utils/debug"
14
15
 
15
16
  const CONFIG_DIR_NAME = ".easiarr"
16
17
  const CONFIG_FILE_NAME = "config.json"
@@ -71,17 +72,21 @@ function migrateConfig(oldConfig: Partial<EasiarrConfig>): EasiarrConfig {
71
72
 
72
73
  export async function loadConfig(): Promise<EasiarrConfig | null> {
73
74
  const configPath = getConfigPath()
75
+ debugLog("Config", `Loading config from ${configPath}`)
74
76
 
75
77
  if (!existsSync(configPath)) {
78
+ debugLog("Config", "Config file not found")
76
79
  return null
77
80
  }
78
81
 
79
82
  try {
80
83
  const content = await readFile(configPath, "utf-8")
81
84
  let config = JSON.parse(content) as EasiarrConfig
85
+ debugLog("Config", `Loaded config version ${config.version}`)
82
86
 
83
87
  // Auto-migrate if version differs from current package version
84
88
  if (config.version !== VersionInfo.version) {
89
+ debugLog("Config", `Migrating config from ${config.version} to ${VersionInfo.version}`)
85
90
  config = migrateConfig(config)
86
91
  // Save migrated config (creates backup first)
87
92
  await saveConfig(config)
@@ -89,6 +94,7 @@ export async function loadConfig(): Promise<EasiarrConfig | null> {
89
94
 
90
95
  return config
91
96
  } catch (error) {
97
+ debugLog("Config", `Failed to load config: ${error}`)
92
98
  console.error("Failed to load config:", error)
93
99
  return null
94
100
  }
@@ -98,6 +104,7 @@ export async function saveConfig(config: EasiarrConfig): Promise<void> {
98
104
  await ensureConfigDir()
99
105
 
100
106
  const configPath = getConfigPath()
107
+ debugLog("Config", `Saving config to ${configPath}`)
101
108
 
102
109
  // Create backup if config already exists
103
110
  if (existsSync(configPath)) {
@@ -108,6 +115,7 @@ export async function saveConfig(config: EasiarrConfig): Promise<void> {
108
115
  config.updatedAt = new Date().toISOString()
109
116
 
110
117
  await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8")
118
+ debugLog("Config", `Config saved (${config.apps.length} apps)`)
111
119
  }
112
120
 
113
121
  export async function backupConfig(): Promise<void> {
@@ -122,6 +130,7 @@ export async function backupConfig(): Promise<void> {
122
130
  const backupPath = join(backupDir, `config-${timestamp}.json`)
123
131
 
124
132
  await copyFile(configPath, backupPath)
133
+ debugLog("Config", `Backup created: ${backupPath}`)
125
134
  }
126
135
 
127
136
  export function createDefaultConfig(rootDir: string): EasiarrConfig {
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { $ } from "bun"
7
7
  import { getComposePath } from "../config/manager"
8
+ import { debugLog } from "../utils/debug"
8
9
 
9
10
  export interface ContainerStatus {
10
11
  name: string
@@ -18,9 +19,12 @@ export async function composeUp(): Promise<{
18
19
  }> {
19
20
  try {
20
21
  const composePath = getComposePath()
22
+ debugLog("Docker", `compose up -d (path: ${composePath})`)
21
23
  const result = await $`docker compose -f ${composePath} up -d`.text()
24
+ debugLog("Docker", `compose up success`)
22
25
  return { success: true, output: result }
23
26
  } catch (error) {
27
+ debugLog("Docker", `compose up failed: ${error}`)
24
28
  return { success: false, output: String(error) }
25
29
  }
26
30
  }
@@ -31,9 +35,12 @@ export async function composeDown(): Promise<{
31
35
  }> {
32
36
  try {
33
37
  const composePath = getComposePath()
38
+ debugLog("Docker", `compose down (path: ${composePath})`)
34
39
  const result = await $`docker compose -f ${composePath} down`.text()
40
+ debugLog("Docker", `compose down success`)
35
41
  return { success: true, output: result }
36
42
  } catch (error) {
43
+ debugLog("Docker", `compose down failed: ${error}`)
37
44
  return { success: false, output: String(error) }
38
45
  }
39
46
  }
@@ -41,11 +48,14 @@ export async function composeDown(): Promise<{
41
48
  export async function composeRestart(service?: string): Promise<{ success: boolean; output: string }> {
42
49
  try {
43
50
  const composePath = getComposePath()
51
+ debugLog("Docker", `compose restart ${service || "all"}`)
44
52
  const result = service
45
53
  ? await $`docker compose -f ${composePath} restart ${service}`.text()
46
54
  : await $`docker compose -f ${composePath} restart`.text()
55
+ debugLog("Docker", `compose restart success`)
47
56
  return { success: true, output: result }
48
57
  } catch (error) {
58
+ debugLog("Docker", `compose restart failed: ${error}`)
49
59
  return { success: false, output: String(error) }
50
60
  }
51
61
  }
@@ -53,6 +63,7 @@ export async function composeRestart(service?: string): Promise<{ success: boole
53
63
  export async function composeStop(service?: string): Promise<{ success: boolean; output: string }> {
54
64
  try {
55
65
  const composePath = getComposePath()
66
+ debugLog("Docker", `compose stop ${service || "all"}`)
56
67
  const result = service
57
68
  ? await $`docker compose -f ${composePath} stop ${service}`.text()
58
69
  : await $`docker compose -f ${composePath} stop`.text()
@@ -7,6 +7,7 @@ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/c
7
7
  import { createPageLayout } from "../components/PageLayout"
8
8
  import type { EasiarrConfig } from "../../config/schema"
9
9
  import { ArrApiClient, type AddRootFolderOptions } from "../../api/arr-api"
10
+ import { BazarrApiClient } from "../../api/bazarr-api"
10
11
  import { ProwlarrClient, type ArrAppType } from "../../api/prowlarr-api"
11
12
  import { QBittorrentClient, type QBittorrentCategory } from "../../api/qbittorrent-api"
12
13
  import { PortainerApiClient } from "../../api/portainer-api"
@@ -201,6 +202,7 @@ export class FullAutoSetup extends BoxRenderable {
201
202
  }
202
203
 
203
204
  try {
205
+ // Setup *arr apps (Radarr, Sonarr, Lidarr, etc.) with form auth
204
206
  const arrApps = this.config.apps.filter((a) => {
205
207
  const def = getApp(a.id)
206
208
  return a.enabled && (def?.rootFolder || a.id === "prowlarr")
@@ -224,6 +226,56 @@ export class FullAutoSetup extends BoxRenderable {
224
226
  }
225
227
  }
226
228
 
229
+ // Setup Bazarr form authentication and Radarr/Sonarr connections
230
+ const bazarrConfig = this.config.apps.find((a) => a.id === "bazarr" && a.enabled)
231
+ if (bazarrConfig) {
232
+ const bazarrApiKey = this.env["API_KEY_BAZARR"]
233
+ if (bazarrApiKey) {
234
+ const bazarrDef = getApp("bazarr")
235
+ const bazarrPort = bazarrConfig.port || bazarrDef?.defaultPort || 6767
236
+ const bazarrClient = new BazarrApiClient("localhost", bazarrPort)
237
+ bazarrClient.setApiKey(bazarrApiKey)
238
+
239
+ try {
240
+ // Enable form auth
241
+ await bazarrClient.enableFormAuth(this.globalUsername, this.globalPassword, false)
242
+ } catch {
243
+ // Skip Bazarr auth failure - non-critical
244
+ debugLog("FullAutoSetup", "Bazarr form auth failed, continuing...")
245
+ }
246
+
247
+ // Configure Radarr connection if Radarr is enabled
248
+ // Use container name 'radarr' since Bazarr runs in Docker
249
+ const radarrConfig = this.config.apps.find((a) => a.id === "radarr" && a.enabled)
250
+ const radarrApiKey = this.env["API_KEY_RADARR"]
251
+ if (radarrConfig && radarrApiKey) {
252
+ try {
253
+ const radarrDef = getApp("radarr")
254
+ const radarrPort = radarrConfig.port || radarrDef?.defaultPort || 7878
255
+ await bazarrClient.configureRadarr("radarr", radarrPort, radarrApiKey)
256
+ debugLog("FullAutoSetup", "Bazarr -> Radarr connection configured")
257
+ } catch {
258
+ debugLog("FullAutoSetup", "Failed to configure Bazarr -> Radarr connection")
259
+ }
260
+ }
261
+
262
+ // Configure Sonarr connection if Sonarr is enabled
263
+ // Use container name 'sonarr' since Bazarr runs in Docker
264
+ const sonarrConfig = this.config.apps.find((a) => a.id === "sonarr" && a.enabled)
265
+ const sonarrApiKey = this.env["API_KEY_SONARR"]
266
+ if (sonarrConfig && sonarrApiKey) {
267
+ try {
268
+ const sonarrDef = getApp("sonarr")
269
+ const sonarrPort = sonarrConfig.port || sonarrDef?.defaultPort || 8989
270
+ await bazarrClient.configureSonarr("sonarr", sonarrPort, sonarrApiKey)
271
+ debugLog("FullAutoSetup", "Bazarr -> Sonarr connection configured")
272
+ } catch {
273
+ debugLog("FullAutoSetup", "Failed to configure Bazarr -> Sonarr connection")
274
+ }
275
+ }
276
+ }
277
+ }
278
+
227
279
  this.updateStep("Authentication", "success")
228
280
  } catch (e) {
229
281
  this.updateStep("Authentication", "error", `${e}`)
@@ -155,12 +155,13 @@ export class MainMenu {
155
155
  })
156
156
  }
157
157
  // Bookmark generation options
158
- const generateAndOpenBookmarks = async (useLocalUrls: boolean) => {
159
- await saveBookmarks(this.config, useLocalUrls)
158
+ const generateAndOpenBookmarks = async (type: "local" | "remote") => {
159
+ await saveBookmarks(this.config, type)
160
160
  // Open in browser if easiarr service is enabled
161
161
  if (this.isAppEnabled("easiarr")) {
162
162
  const port = this.config.apps.find((a) => a.id === "easiarr")?.port ?? 3010
163
- await openUrl(`http://localhost:${port}/bookmarks.html`)
163
+ const filename = type === "remote" ? "bookmarks-remote.html" : "bookmarks-local.html"
164
+ await openUrl(`http://localhost:${port}/${filename}`)
164
165
  }
165
166
  }
166
167
 
@@ -168,18 +169,18 @@ export class MainMenu {
168
169
  items.push({
169
170
  name: "📑 Bookmarks (Local URLs)",
170
171
  description: "Generate bookmarks using localhost addresses",
171
- action: async () => generateAndOpenBookmarks(true),
172
+ action: async () => generateAndOpenBookmarks("local"),
172
173
  })
173
174
  items.push({
174
175
  name: "📑 Bookmarks (Traefik URLs)",
175
176
  description: `Generate bookmarks using ${this.config.traefik.domain} addresses`,
176
- action: async () => generateAndOpenBookmarks(false),
177
+ action: async () => generateAndOpenBookmarks("remote"),
177
178
  })
178
179
  } else {
179
180
  items.push({
180
181
  name: "📑 Generate Bookmarks",
181
182
  description: "Create browser-importable bookmarks file",
182
- action: async () => generateAndOpenBookmarks(true),
183
+ action: async () => generateAndOpenBookmarks("local"),
183
184
  })
184
185
  }
185
186
 
@@ -31,14 +31,29 @@ export function initDebug(): void {
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * Sanitize sensitive fields from log messages
36
+ * Redacts passwords, tokens, API keys, secrets, and credentials
37
+ */
38
+ export function sanitizeMessage(message: string): string {
39
+ // Match common sensitive field names in JSON format
40
+ // Covers: passwords, tokens, API keys, secrets, credentials, auth data
41
+ return message.replace(
42
+ /"(password|passwordConfirmation|Password|Pw|passwd|pass|apiKey|api_key|ApiKey|API_KEY|token|accessToken|access_token|refreshToken|refresh_token|bearerToken|jwtToken|jwt|secret|secretKey|secret_key|privateKey|private_key|credential|auth|authorization|authToken|client_secret|clientSecret|WIREGUARD_PRIVATE_KEY|TUNNEL_TOKEN|USERNAME_VPN|PASSWORD_VPN)":\s*"[^"]*"/gi,
43
+ '"$1":"[REDACTED]"'
44
+ )
45
+ }
46
+
34
47
  /**
35
48
  * Log a debug message to debug.log file if debug mode is enabled
49
+ * Automatically sanitizes sensitive data (passwords, tokens, etc.)
36
50
  */
37
51
  export function debugLog(category: string, message: string): void {
38
52
  if (!DEBUG_ENABLED) return
39
53
 
40
54
  const timestamp = new Date().toISOString()
41
- const line = `[${timestamp}] [${category}] ${message}\n`
55
+ const sanitizedMessage = sanitizeMessage(message)
56
+ const line = `[${timestamp}] [${category}] ${sanitizedMessage}\n`
42
57
  try {
43
58
  appendFileSync(logFile, line)
44
59
  } catch {