@muhammedaksam/easiarr 0.1.9 → 0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.1.9",
3
+ "version": "0.2.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",
@@ -26,42 +26,16 @@ export interface DownloadClient extends DownloadClientConfig {
26
26
  id?: number
27
27
  }
28
28
 
29
- import type { AppId } from "../config/schema"
30
-
31
- // Get category name for an app
32
- function getCategoryForApp(appId: AppId): string {
33
- switch (appId) {
34
- case "radarr":
35
- return "movies"
36
- case "sonarr":
37
- return "tv"
38
- case "lidarr":
39
- return "music"
40
- case "readarr":
41
- return "books"
42
- case "whisparr":
43
- return "adult"
44
- default:
45
- return "default"
46
- }
29
+ // Types for Remote Path Mapping API
30
+ export interface RemotePathMapping {
31
+ id?: number
32
+ host: string
33
+ remotePath: string
34
+ localPath: string
47
35
  }
48
36
 
49
- // Get category field name for an app (different apps use different field names)
50
- function getCategoryFieldName(appId: AppId): string {
51
- switch (appId) {
52
- case "radarr":
53
- return "movieCategory"
54
- case "sonarr":
55
- case "whisparr":
56
- return "tvCategory"
57
- case "lidarr":
58
- return "musicCategory"
59
- case "readarr":
60
- return "bookCategory"
61
- default:
62
- return "category"
63
- }
64
- }
37
+ import type { AppId } from "../config/schema"
38
+ import { getCategoryForApp, getCategoryFieldName } from "../utils/categories"
65
39
 
66
40
  // qBittorrent download client config
67
41
  export function createQBittorrentConfig(
@@ -86,6 +60,7 @@ export function createQBittorrentConfig(
86
60
  { name: "username", value: username },
87
61
  { name: "password", value: password },
88
62
  { name: categoryField, value: category },
63
+ { name: "savePath", value: "/data/torrents" },
89
64
  { name: "recentMoviePriority", value: 0 },
90
65
  { name: "olderMoviePriority", value: 0 },
91
66
  { name: "initialState", value: 0 },
@@ -111,6 +86,7 @@ export function createSABnzbdConfig(host: string, port: number, apiKey: string,
111
86
  { name: "port", value: port },
112
87
  { name: "apiKey", value: apiKey },
113
88
  { name: categoryField, value: category },
89
+ { name: "savePath", value: "/data/usenet" },
114
90
  { name: "recentMoviePriority", value: -100 },
115
91
  { name: "olderMoviePriority", value: -100 },
116
92
  ],
@@ -226,6 +202,22 @@ export class ArrApiClient {
226
202
  body: JSON.stringify(updatedConfig),
227
203
  })
228
204
  }
205
+
206
+ // Remote Path Mapping methods - for Docker path translation
207
+ async getRemotePathMappings(): Promise<RemotePathMapping[]> {
208
+ return this.request<RemotePathMapping[]>("/remotepathmapping")
209
+ }
210
+
211
+ async addRemotePathMapping(host: string, remotePath: string, localPath: string): Promise<RemotePathMapping> {
212
+ return this.request<RemotePathMapping>("/remotepathmapping", {
213
+ method: "POST",
214
+ body: JSON.stringify({ host, remotePath, localPath }),
215
+ })
216
+ }
217
+
218
+ async deleteRemotePathMapping(id: number): Promise<void> {
219
+ await this.request(`/remotepathmapping/${id}`, { method: "DELETE" })
220
+ }
229
221
  }
230
222
 
231
223
  // Types for Host Config API
@@ -0,0 +1,198 @@
1
+ /**
2
+ * qBittorrent WebAPI Client
3
+ * Configures qBittorrent settings via API
4
+ * API docs: https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)
5
+ */
6
+
7
+ export interface QBittorrentPreferences {
8
+ save_path?: string
9
+ temp_path_enabled?: boolean
10
+ temp_path?: string
11
+ auto_tmm_enabled?: boolean
12
+ category_changed_tmm_enabled?: boolean
13
+ save_path_changed_tmm_enabled?: boolean
14
+ }
15
+
16
+ export interface QBittorrentCategory {
17
+ name: string
18
+ savePath: string
19
+ }
20
+
21
+ export class QBittorrentClient {
22
+ private baseUrl: string
23
+ private username: string
24
+ private password: string
25
+ private cookie: string | null = null
26
+
27
+ constructor(host: string, port: number, username: string, password: string) {
28
+ this.baseUrl = `http://${host}:${port}`
29
+ this.username = username
30
+ this.password = password
31
+ }
32
+
33
+ /**
34
+ * Authenticate with qBittorrent WebUI
35
+ * POST /api/v2/auth/login
36
+ */
37
+ async login(): Promise<boolean> {
38
+ try {
39
+ const response = await fetch(`${this.baseUrl}/api/v2/auth/login`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
42
+ body: `username=${encodeURIComponent(this.username)}&password=${encodeURIComponent(this.password)}`,
43
+ })
44
+
45
+ if (!response.ok) return false
46
+
47
+ // Extract SID cookie from response
48
+ const setCookie = response.headers.get("set-cookie")
49
+ if (setCookie) {
50
+ const match = setCookie.match(/SID=([^;]+)/)
51
+ if (match) {
52
+ this.cookie = `SID=${match[1]}`
53
+ return true
54
+ }
55
+ }
56
+
57
+ // Check response text for "Ok."
58
+ const text = await response.text()
59
+ return text === "Ok."
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Check if connected to qBittorrent
67
+ */
68
+ async isConnected(): Promise<boolean> {
69
+ try {
70
+ const response = await fetch(`${this.baseUrl}/api/v2/app/version`, {
71
+ headers: this.cookie ? { Cookie: this.cookie } : {},
72
+ })
73
+ return response.ok
74
+ } catch {
75
+ return false
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get current preferences
81
+ * GET /api/v2/app/preferences
82
+ */
83
+ async getPreferences(): Promise<QBittorrentPreferences> {
84
+ const response = await fetch(`${this.baseUrl}/api/v2/app/preferences`, {
85
+ headers: this.cookie ? { Cookie: this.cookie } : {},
86
+ })
87
+
88
+ if (!response.ok) {
89
+ throw new Error(`Failed to get preferences: ${response.status}`)
90
+ }
91
+
92
+ return response.json()
93
+ }
94
+
95
+ /**
96
+ * Set preferences
97
+ * POST /api/v2/app/setPreferences
98
+ */
99
+ async setPreferences(prefs: QBittorrentPreferences): Promise<void> {
100
+ const response = await fetch(`${this.baseUrl}/api/v2/app/setPreferences`, {
101
+ method: "POST",
102
+ headers: {
103
+ "Content-Type": "application/x-www-form-urlencoded",
104
+ ...(this.cookie ? { Cookie: this.cookie } : {}),
105
+ },
106
+ body: `json=${encodeURIComponent(JSON.stringify(prefs))}`,
107
+ })
108
+
109
+ if (!response.ok) {
110
+ throw new Error(`Failed to set preferences: ${response.status}`)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Get all categories
116
+ * GET /api/v2/torrents/categories
117
+ */
118
+ async getCategories(): Promise<Record<string, { name: string; savePath: string }>> {
119
+ const response = await fetch(`${this.baseUrl}/api/v2/torrents/categories`, {
120
+ headers: this.cookie ? { Cookie: this.cookie } : {},
121
+ })
122
+
123
+ if (!response.ok) {
124
+ throw new Error(`Failed to get categories: ${response.status}`)
125
+ }
126
+
127
+ return response.json()
128
+ }
129
+
130
+ /**
131
+ * Create a category
132
+ * POST /api/v2/torrents/createCategory
133
+ */
134
+ async createCategory(name: string, savePath: string): Promise<void> {
135
+ const response = await fetch(`${this.baseUrl}/api/v2/torrents/createCategory`, {
136
+ method: "POST",
137
+ headers: {
138
+ "Content-Type": "application/x-www-form-urlencoded",
139
+ ...(this.cookie ? { Cookie: this.cookie } : {}),
140
+ },
141
+ body: `category=${encodeURIComponent(name)}&savePath=${encodeURIComponent(savePath)}`,
142
+ })
143
+
144
+ // 409 means category already exists - that's OK
145
+ if (!response.ok && response.status !== 409) {
146
+ throw new Error(`Failed to create category: ${response.status}`)
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Edit a category's save path
152
+ * POST /api/v2/torrents/editCategory
153
+ */
154
+ async editCategory(name: string, savePath: string): Promise<void> {
155
+ const response = await fetch(`${this.baseUrl}/api/v2/torrents/editCategory`, {
156
+ method: "POST",
157
+ headers: {
158
+ "Content-Type": "application/x-www-form-urlencoded",
159
+ ...(this.cookie ? { Cookie: this.cookie } : {}),
160
+ },
161
+ body: `category=${encodeURIComponent(name)}&savePath=${encodeURIComponent(savePath)}`,
162
+ })
163
+
164
+ if (!response.ok) {
165
+ throw new Error(`Failed to edit category: ${response.status}`)
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Configure qBittorrent for TRaSH Guide compliance
171
+ * Sets proper save paths and creates categories based on enabled apps
172
+ * @param categories - Array of {name, savePath} for each enabled *arr app
173
+ */
174
+ async configureTRaSHCompliant(categories: QBittorrentCategory[] = []): Promise<void> {
175
+ // 1. Set global preferences
176
+ await this.setPreferences({
177
+ save_path: "/data/torrents",
178
+ temp_path_enabled: false,
179
+ auto_tmm_enabled: true,
180
+ category_changed_tmm_enabled: true,
181
+ save_path_changed_tmm_enabled: true,
182
+ })
183
+
184
+ // 2. Create categories for each enabled media type
185
+ for (const cat of categories) {
186
+ try {
187
+ await this.createCategory(cat.name, cat.savePath)
188
+ } catch {
189
+ // Try to update existing category
190
+ try {
191
+ await this.editCategory(cat.name, cat.savePath)
192
+ } catch {
193
+ // Ignore - category might not exist or be locked
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
@@ -246,7 +246,8 @@ export const APPS: Record<AppId, AppDefinition> = {
246
246
  image: "lscr.io/linuxserver/qbittorrent:latest",
247
247
  puid: 13007,
248
248
  pgid: 13000,
249
- volumes: (root) => [`${root}/config/qbittorrent:/config`, `${root}/data/torrents:/data/torrents`],
249
+ // TRaSH: Mount full /data for consistent paths with *arr apps (enables hardlinks)
250
+ volumes: (root) => [`${root}/config/qbittorrent:/config`, `${root}/data:/data`],
250
251
  environment: { WEBUI_PORT: "8080" },
251
252
  trashGuide: "docs/Downloaders/qBittorrent/",
252
253
  },
@@ -260,7 +261,8 @@ export const APPS: Record<AppId, AppDefinition> = {
260
261
  image: "lscr.io/linuxserver/sabnzbd:latest",
261
262
  puid: 13011,
262
263
  pgid: 13000,
263
- volumes: (root) => [`${root}/config/sabnzbd:/config`, `${root}/data/usenet:/data/usenet`],
264
+ // TRaSH: Mount full /data for consistent paths with *arr apps (enables hardlinks)
265
+ volumes: (root) => [`${root}/config/sabnzbd:/config`, `${root}/data:/data`],
264
266
 
265
267
  trashGuide: "docs/Downloaders/SABnzbd/",
266
268
  apiKeyMeta: {
@@ -3,12 +3,12 @@
3
3
  * Generates docker-compose.yml from Easiarr configuration
4
4
  */
5
5
 
6
- import { writeFile, readFile } from "node:fs/promises"
7
- import { existsSync } from "node:fs"
6
+ import { writeFile } from "node:fs/promises"
8
7
  import type { EasiarrConfig, AppConfig, TraefikConfig, AppId } from "../config/schema"
9
8
  import { getComposePath } from "../config/manager"
10
9
  import { getApp } from "../apps/registry"
11
10
  import { generateServiceYaml } from "./templates"
11
+ import { updateEnv } from "../utils/env"
12
12
 
13
13
  export interface ComposeService {
14
14
  image: string
@@ -205,30 +205,12 @@ export async function saveCompose(config: EasiarrConfig): Promise<string> {
205
205
  }
206
206
 
207
207
  async function updateEnvFile(config: EasiarrConfig) {
208
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
209
- let envContent = ""
210
-
211
- // Read existing .env if present to preserve secrets
212
- const currentEnv: Record<string, string> = {}
213
- if (existsSync(envPath)) {
214
- const content = await readFile(envPath, "utf-8")
215
- content.split("\n").forEach((line) => {
216
- const [key, ...val] = line.split("=")
217
- if (key && val) currentEnv[key.trim()] = val.join("=").trim()
218
- })
219
- }
220
-
221
- // Update/Set globals
222
- currentEnv["ROOT_DIR"] = config.rootDir
223
- currentEnv["TIMEZONE"] = config.timezone
224
- currentEnv["PUID"] = config.uid.toString()
225
- currentEnv["PGID"] = config.gid.toString()
226
- currentEnv["UMASK"] = config.umask
227
-
228
- // Reconstruct .env content
229
- envContent = Object.entries(currentEnv)
230
- .map(([k, v]) => `${k}=${v}`)
231
- .join("\n")
232
-
233
- await writeFile(envPath, envContent, "utf-8")
208
+ // Update .env with global configuration values
209
+ await updateEnv({
210
+ ROOT_DIR: config.rootDir,
211
+ TIMEZONE: config.timezone,
212
+ PUID: config.uid.toString(),
213
+ PGID: config.gid.toString(),
214
+ UMASK: config.umask,
215
+ })
234
216
  }
@@ -17,6 +17,7 @@ import { createPageLayout } from "../components/PageLayout"
17
17
  import { FileEditor } from "../components/FileEditor"
18
18
  import { readFile, writeFile } from "node:fs/promises"
19
19
  import { getConfigPath, getComposePath } from "../../config/manager"
20
+ import { getEnvPath } from "../../utils/env"
20
21
  import { existsSync } from "node:fs"
21
22
 
22
23
  export class AdvancedSettings {
@@ -100,7 +101,7 @@ export class AdvancedSettings {
100
101
  })
101
102
  break
102
103
  case 1: {
103
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
104
+ const envPath = getEnvPath()
104
105
  await this.editFile(".env", envPath, async (content) => {
105
106
  await writeFile(envPath, content, "utf-8")
106
107
  this.renderMenu()
@@ -1,13 +1,12 @@
1
1
  import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs"
3
- import { writeFile, readFile } from "node:fs/promises"
4
3
  import { join } from "node:path"
5
4
  import { randomBytes } from "node:crypto"
6
5
  import { parse as parseYaml } from "yaml"
7
6
  import { createPageLayout } from "../components/PageLayout"
8
7
  import { EasiarrConfig, AppDefinition } from "../../config/schema"
9
8
  import { getApp } from "../../apps/registry"
10
- import { getComposePath } from "../../config/manager"
9
+ import { updateEnv } from "../../utils/env"
11
10
 
12
11
  /** Generate a random 32-character hex API key */
13
12
  function generateApiKey(): string {
@@ -337,30 +336,13 @@ export class ApiKeyViewer extends BoxRenderable {
337
336
  if (foundKeys.length === 0) return
338
337
 
339
338
  try {
340
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
341
-
342
- // Read existing .env if present
343
- const currentEnv: Record<string, string> = {}
344
- if (existsSync(envPath)) {
345
- const content = await readFile(envPath, "utf-8")
346
- content.split("\n").forEach((line) => {
347
- const [key, ...val] = line.split("=")
348
- if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
349
- })
350
- }
351
-
352
- // Add API keys with format API_KEY_SONARR, API_KEY_RADARR, etc.
339
+ // Build updates object with API keys
340
+ const updates: Record<string, string> = {}
353
341
  for (const k of foundKeys) {
354
- const envKey = `API_KEY_${k.appId.toUpperCase()}`
355
- currentEnv[envKey] = k.key
342
+ updates[`API_KEY_${k.appId.toUpperCase()}`] = k.key
356
343
  }
357
344
 
358
- // Reconstruct .env content
359
- const envContent = Object.entries(currentEnv)
360
- .map(([k, v]) => `${k}=${v}`)
361
- .join("\n")
362
-
363
- await writeFile(envPath, envContent, "utf-8")
345
+ await updateEnv(updates)
364
346
 
365
347
  // Update status
366
348
  if (this.statusText) {
@@ -11,14 +11,13 @@ import {
11
11
  InputRenderableEvents,
12
12
  KeyEvent,
13
13
  } from "@opentui/core"
14
- import { existsSync, readFileSync } from "node:fs"
15
- import { writeFile, readFile } from "node:fs/promises"
16
- import { join } from "node:path"
17
14
  import { createPageLayout } from "../components/PageLayout"
18
15
  import { EasiarrConfig, AppId } from "../../config/schema"
19
16
  import { getApp } from "../../apps/registry"
20
17
  import { ArrApiClient, createQBittorrentConfig, createSABnzbdConfig } from "../../api/arr-api"
21
- import { getComposePath } from "../../config/manager"
18
+ import { QBittorrentClient } from "../../api/qbittorrent-api"
19
+ import { getCategoriesForApps } from "../../utils/categories"
20
+ import { readEnvSync, updateEnv } from "../../utils/env"
22
21
 
23
22
  interface ConfigResult {
24
23
  appId: AppId
@@ -83,29 +82,11 @@ export class AppConfigurator extends BoxRenderable {
83
82
  }
84
83
 
85
84
  private loadSavedCredentials() {
86
- try {
87
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
88
- if (!existsSync(envPath)) return
89
-
90
- const content = readFileSync(envPath, "utf-8")
91
- content.split("\n").forEach((line) => {
92
- const [key, ...val] = line.split("=")
93
- if (key && val.length > 0) {
94
- const value = val.join("=").trim()
95
- if (key.trim() === "GLOBAL_USERNAME") {
96
- this.globalUsername = value
97
- } else if (key.trim() === "GLOBAL_PASSWORD") {
98
- this.globalPassword = value
99
- } else if (key.trim() === "QBITTORRENT_PASSWORD") {
100
- this.qbPass = value
101
- } else if (key.trim() === "SABNZBD_API_KEY") {
102
- this.sabApiKey = value
103
- }
104
- }
105
- })
106
- } catch {
107
- // Ignore errors
108
- }
85
+ const env = readEnvSync()
86
+ if (env.GLOBAL_USERNAME) this.globalUsername = env.GLOBAL_USERNAME
87
+ if (env.GLOBAL_PASSWORD) this.globalPassword = env.GLOBAL_PASSWORD
88
+ if (env.QBITTORRENT_PASSWORD) this.qbPass = env.QBITTORRENT_PASSWORD
89
+ if (env.SABNZBD_API_KEY) this.sabApiKey = env.SABNZBD_API_KEY
109
90
  }
110
91
 
111
92
  private renderCredentialsPrompt() {
@@ -114,7 +95,7 @@ export class AppConfigurator extends BoxRenderable {
114
95
  const { container, content } = createPageLayout(this.cliRenderer, {
115
96
  title: "Configure Apps",
116
97
  stepInfo: "Global Credentials",
117
- footerHint: "Enter credentials for all *arr apps Tab Next Field Enter Continue Esc Skip",
98
+ footerHint: "Tab Cycle Fields/Shortcuts O Override Enter Continue Esc Skip",
118
99
  })
119
100
  this.pageContainer = container
120
101
  this.add(container)
@@ -174,13 +155,16 @@ export class AppConfigurator extends BoxRenderable {
174
155
  overrideText.content = `[O] Override existing: ${this.overrideExisting ? "Yes" : "No"}`
175
156
  overrideText.fg = this.overrideExisting ? "#50fa7b" : "#6272a4"
176
157
  } else if (key.name === "tab") {
177
- // Toggle focus between inputs
158
+ // Cycle focus: username -> password -> no focus (shortcuts work) -> username
178
159
  if (focusedInput === userInput) {
179
160
  userInput.blur()
180
161
  passInput.focus()
181
162
  focusedInput = passInput
182
- } else {
163
+ } else if (focusedInput === passInput) {
183
164
  passInput.blur()
165
+ focusedInput = null // No focus state - shortcuts available
166
+ } else {
167
+ // No input focused, go back to username
184
168
  userInput.focus()
185
169
  focusedInput = userInput
186
170
  }
@@ -214,32 +198,10 @@ export class AppConfigurator extends BoxRenderable {
214
198
 
215
199
  private async saveGlobalCredentialsToEnv() {
216
200
  try {
217
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
218
-
219
- // Read existing .env if present
220
- const currentEnv: Record<string, string> = {}
221
- if (existsSync(envPath)) {
222
- const content = await readFile(envPath, "utf-8")
223
- content.split("\n").forEach((line) => {
224
- const [key, ...val] = line.split("=")
225
- if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
226
- })
227
- }
228
-
229
- // Add global credentials
230
- if (this.globalUsername) {
231
- currentEnv["GLOBAL_USERNAME"] = this.globalUsername
232
- }
233
- if (this.globalPassword) {
234
- currentEnv["GLOBAL_PASSWORD"] = this.globalPassword
235
- }
236
-
237
- // Reconstruct .env content
238
- const envContent = Object.entries(currentEnv)
239
- .map(([k, v]) => `${k}=${v}`)
240
- .join("\n")
241
-
242
- await writeFile(envPath, envContent, "utf-8")
201
+ const updates: Record<string, string> = {}
202
+ if (this.globalUsername) updates.GLOBAL_USERNAME = this.globalUsername
203
+ if (this.globalPassword) updates.GLOBAL_PASSWORD = this.globalPassword
204
+ await updateEnv(updates)
243
205
  } catch {
244
206
  // Ignore errors - not critical
245
207
  }
@@ -343,27 +305,17 @@ export class AppConfigurator extends BoxRenderable {
343
305
  }
344
306
 
345
307
  private extractApiKey(appId: AppId): string | null {
346
- const appDef = getApp(appId)
347
- if (!appDef?.apiKeyMeta) return null
348
-
349
- const volumes = appDef.volumes(this.config.rootDir)
350
- if (volumes.length === 0) return null
351
-
352
- const parts = volumes[0].split(":")
353
- const hostPath = parts[0]
354
- const configFilePath = join(hostPath, appDef.apiKeyMeta.configFile)
355
-
356
- if (!existsSync(configFilePath)) return null
357
-
358
- const content = readFileSync(configFilePath, "utf-8")
359
-
360
- if (appDef.apiKeyMeta.parser === "regex") {
361
- const regex = new RegExp(appDef.apiKeyMeta.selector)
362
- const match = regex.exec(content)
363
- return match?.[1] || null
364
- }
308
+ // Use API keys from .env file (format: API_KEY_APPNAME)
309
+ const envKey = `API_KEY_${appId.toUpperCase()}`
310
+ return readEnvSync()[envKey] ?? null
311
+ }
365
312
 
366
- return null
313
+ /**
314
+ * Get qBittorrent categories based on enabled *arr apps
315
+ */
316
+ private getEnabledCategories(): { name: string; savePath: string }[] {
317
+ const enabledAppIds = this.config.apps.filter((a) => a.enabled).map((a) => a.id)
318
+ return getCategoriesForApps(enabledAppIds)
367
319
  }
368
320
 
369
321
  private renderConfigProgress() {
@@ -543,6 +495,22 @@ export class AppConfigurator extends BoxRenderable {
543
495
  }
544
496
 
545
497
  private async addDownloadClients(type: "qbittorrent" | "sabnzbd") {
498
+ // Configure qBittorrent settings via its API first
499
+ if (type === "qbittorrent") {
500
+ try {
501
+ const qbClient = new QBittorrentClient(this.qbHost, this.qbPort, this.qbUser, this.qbPass)
502
+ const loggedIn = await qbClient.login()
503
+ if (loggedIn) {
504
+ // Generate categories from enabled *arr apps that use download clients
505
+ const categories = this.getEnabledCategories()
506
+ // Configure TRaSH-compliant settings: save_path, auto_tmm, categories
507
+ await qbClient.configureTRaSHCompliant(categories)
508
+ }
509
+ } catch {
510
+ // Ignore qBittorrent config errors - may not be ready or have different auth
511
+ }
512
+ }
513
+
546
514
  // Add download client to all *arr apps
547
515
  const servarrApps = this.config.apps.filter((a) => {
548
516
  const def = getApp(a.id)
@@ -587,31 +555,13 @@ export class AppConfigurator extends BoxRenderable {
587
555
 
588
556
  private async saveCredentialsToEnv(type: "qbittorrent" | "sabnzbd") {
589
557
  try {
590
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
591
-
592
- // Read existing .env if present
593
- const currentEnv: Record<string, string> = {}
594
- if (existsSync(envPath)) {
595
- const content = await readFile(envPath, "utf-8")
596
- content.split("\n").forEach((line) => {
597
- const [key, ...val] = line.split("=")
598
- if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
599
- })
600
- }
601
-
602
- // Add credentials
558
+ const updates: Record<string, string> = {}
603
559
  if (type === "qbittorrent" && this.qbPass) {
604
- currentEnv["QBITTORRENT_PASSWORD"] = this.qbPass
560
+ updates.QBITTORRENT_PASSWORD = this.qbPass
605
561
  } else if (type === "sabnzbd" && this.sabApiKey) {
606
- currentEnv["SABNZBD_API_KEY"] = this.sabApiKey
562
+ updates.SABNZBD_API_KEY = this.sabApiKey
607
563
  }
608
-
609
- // Reconstruct .env content
610
- const envContent = Object.entries(currentEnv)
611
- .map(([k, v]) => `${k}=${v}`)
612
- .join("\n")
613
-
614
- await writeFile(envPath, envContent, "utf-8")
564
+ await updateEnv(updates)
615
565
  } catch {
616
566
  // Ignore errors - not critical
617
567
  }
@@ -10,10 +10,9 @@ import {
10
10
  } from "@opentui/core"
11
11
  import { EasiarrConfig, AppSecret } from "../../config/schema"
12
12
  import { getApp } from "../../apps/registry"
13
- import { getComposePath } from "../../config/manager"
14
- import { readFile, writeFile, mkdir } from "node:fs/promises"
13
+ import { readEnv, updateEnv, getEnvPath } from "../../utils/env"
14
+ import { mkdir } from "node:fs/promises"
15
15
  import { dirname } from "node:path"
16
- import { existsSync } from "node:fs"
17
16
 
18
17
  export interface SecretsEditorOptions extends BoxOptions {
19
18
  config: EasiarrConfig
@@ -177,65 +176,25 @@ export class SecretsEditor extends BoxRenderable {
177
176
  }
178
177
 
179
178
  private async loadEnv() {
180
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
181
- if (existsSync(envPath)) {
182
- try {
183
- const content = await readFile(envPath, "utf-8")
184
- content.split("\n").forEach((line) => {
185
- const parts = line.split("=")
186
- if (parts.length >= 2) {
187
- const key = parts[0].trim()
188
- const value = parts.slice(1).join("=").trim()
189
- // Remove potential quotes
190
- this.envValues[key] = value.replace(/^["'](.*?)["']$/, "$1")
191
- }
192
- })
193
- } catch (e) {
194
- console.error("Failed to read .env", e)
195
- }
196
- }
179
+ this.envValues = await readEnv()
197
180
  }
198
181
 
199
182
  private async save() {
200
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
201
-
202
- // Read existing .env to preserve other values
203
- const currentEnv: Record<string, string> = {}
204
- if (existsSync(envPath)) {
205
- try {
206
- const content = await readFile(envPath, "utf-8")
207
- content.split("\n").forEach((line) => {
208
- const [key, ...val] = line.split("=")
209
- if (key && val.length > 0) {
210
- currentEnv[key.trim()] = val
211
- .join("=")
212
- .trim()
213
- .replace(/^["'](.*?)["']$/, "$1")
214
- }
215
- })
216
- } catch {
217
- // Ignore read errors
218
- }
219
- }
220
-
221
- // Update with new values from inputs
183
+ // Collect values from inputs
184
+ const updates: Record<string, string> = {}
222
185
  this.inputs.forEach((input, key) => {
223
- currentEnv[key] = input.value
186
+ updates[key] = input.value
224
187
  })
225
188
 
226
189
  // Ensure directory exists
227
190
  try {
228
- await mkdir(dirname(envPath), { recursive: true })
191
+ await mkdir(dirname(getEnvPath()), { recursive: true })
229
192
  } catch {
230
193
  // Ignore if exists
231
194
  }
232
195
 
233
- // Write back
234
- const envContent = Object.entries(currentEnv)
235
- .map(([k, v]) => `${k}=${v}`)
236
- .join("\n")
237
-
238
- await writeFile(envPath, envContent, "utf-8")
196
+ // Update .env file
197
+ await updateEnv(updates)
239
198
 
240
199
  this.onSave()
241
200
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Download Category Utilities
3
+ * Shared category mappings for *arr apps and download clients
4
+ */
5
+
6
+ import type { AppId } from "../config/schema"
7
+
8
+ // Category info for each *arr app
9
+ export interface CategoryInfo {
10
+ name: string
11
+ savePath: string
12
+ fieldName: string // Field name used in *arr download client config
13
+ }
14
+
15
+ // Master mapping of app IDs to their download categories
16
+ const CATEGORY_MAP: Partial<Record<AppId, CategoryInfo>> = {
17
+ radarr: { name: "movies", savePath: "/data/torrents/movies", fieldName: "movieCategory" },
18
+ sonarr: { name: "tv", savePath: "/data/torrents/tv", fieldName: "tvCategory" },
19
+ lidarr: { name: "music", savePath: "/data/torrents/music", fieldName: "musicCategory" },
20
+ readarr: { name: "books", savePath: "/data/torrents/books", fieldName: "bookCategory" },
21
+ whisparr: { name: "adult", savePath: "/data/torrents/adult", fieldName: "tvCategory" },
22
+ mylar3: { name: "comics", savePath: "/data/torrents/comics", fieldName: "category" },
23
+ }
24
+
25
+ /**
26
+ * Get category name for an app (e.g., "movies" for radarr)
27
+ */
28
+ export function getCategoryForApp(appId: AppId): string {
29
+ return CATEGORY_MAP[appId]?.name ?? "default"
30
+ }
31
+
32
+ /**
33
+ * Get the field name used in *arr download client config for category
34
+ * (e.g., "movieCategory" for radarr, "tvCategory" for sonarr)
35
+ */
36
+ export function getCategoryFieldName(appId: AppId): string {
37
+ return CATEGORY_MAP[appId]?.fieldName ?? "category"
38
+ }
39
+
40
+ /**
41
+ * Get the save path for an app's downloads (e.g., "/data/torrents/movies" for radarr)
42
+ */
43
+ export function getCategorySavePath(appId: AppId): string {
44
+ return CATEGORY_MAP[appId]?.savePath ?? "/data/torrents"
45
+ }
46
+
47
+ /**
48
+ * Get full category info for an app
49
+ */
50
+ export function getCategoryInfo(appId: AppId): CategoryInfo | undefined {
51
+ return CATEGORY_MAP[appId]
52
+ }
53
+
54
+ /**
55
+ * Get all category infos for a list of enabled app IDs
56
+ */
57
+ export function getCategoriesForApps(appIds: AppId[]): CategoryInfo[] {
58
+ return appIds.map((id) => CATEGORY_MAP[id]).filter((info): info is CategoryInfo => info !== undefined)
59
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Environment File Utilities
3
+ * Shared functions for reading/writing .env files
4
+ */
5
+
6
+ import { existsSync, readFileSync } from "node:fs"
7
+ import { writeFile, readFile } from "node:fs/promises"
8
+ import { getComposePath } from "../config/manager"
9
+
10
+ /**
11
+ * Get the path to the .env file
12
+ */
13
+ export function getEnvPath(): string {
14
+ return getComposePath().replace("docker-compose.yml", ".env")
15
+ }
16
+
17
+ /**
18
+ * Parse an .env file into a key-value object
19
+ * Preserves existing values and handles multi-part values (e.g., with = in them)
20
+ */
21
+ export function parseEnvFile(content: string): Record<string, string> {
22
+ const env: Record<string, string> = {}
23
+
24
+ for (const line of content.split("\n")) {
25
+ const trimmed = line.trim()
26
+ if (!trimmed || trimmed.startsWith("#")) continue
27
+
28
+ const [key, ...val] = trimmed.split("=")
29
+ if (key && val.length > 0) {
30
+ env[key.trim()] = val.join("=").trim()
31
+ }
32
+ }
33
+
34
+ return env
35
+ }
36
+
37
+ /**
38
+ * Serialize an env object to .env file format
39
+ */
40
+ export function serializeEnv(env: Record<string, string>): string {
41
+ return Object.entries(env)
42
+ .map(([k, v]) => `${k}=${v}`)
43
+ .join("\n")
44
+ }
45
+
46
+ /**
47
+ * Read the .env file and return parsed key-value object
48
+ * Returns empty object if file doesn't exist
49
+ */
50
+ export function readEnvSync(): Record<string, string> {
51
+ const envPath = getEnvPath()
52
+ if (!existsSync(envPath)) return {}
53
+
54
+ try {
55
+ const content = readFileSync(envPath, "utf-8")
56
+ return parseEnvFile(content)
57
+ } catch {
58
+ return {}
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Read the .env file asynchronously
64
+ */
65
+ export async function readEnv(): Promise<Record<string, string>> {
66
+ const envPath = getEnvPath()
67
+ if (!existsSync(envPath)) return {}
68
+
69
+ try {
70
+ const content = await readFile(envPath, "utf-8")
71
+ return parseEnvFile(content)
72
+ } catch {
73
+ return {}
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Write to .env file, merging with existing values
79
+ * Preserves existing keys not in the updates object
80
+ */
81
+ export async function updateEnv(updates: Record<string, string>): Promise<void> {
82
+ const envPath = getEnvPath()
83
+ const current = await readEnv()
84
+
85
+ // Merge updates into current
86
+ const merged = { ...current, ...updates }
87
+
88
+ // Write back
89
+ const content = serializeEnv(merged)
90
+ await writeFile(envPath, content, "utf-8")
91
+ }
92
+
93
+ /**
94
+ * Get a specific value from .env
95
+ */
96
+ export function getEnvValue(key: string): string | undefined {
97
+ return readEnvSync()[key]
98
+ }