@muhammedaksam/easiarr 0.1.7 → 0.1.9

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.7",
3
+ "version": "0.1.9",
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",
@@ -50,9 +50,9 @@ function getCategoryForApp(appId: AppId): string {
50
50
  function getCategoryFieldName(appId: AppId): string {
51
51
  switch (appId) {
52
52
  case "radarr":
53
- case "whisparr":
54
53
  return "movieCategory"
55
54
  case "sonarr":
55
+ case "whisparr":
56
56
  return "tvCategory"
57
57
  case "lidarr":
58
58
  return "musicCategory"
@@ -195,4 +195,77 @@ export class ArrApiClient {
195
195
  return false
196
196
  }
197
197
  }
198
+
199
+ // Host Config methods - for setting up authentication
200
+ async getHostConfig(): Promise<HostConfig> {
201
+ return this.request<HostConfig>("/config/host")
202
+ }
203
+
204
+ async updateHostConfig(username: string, password: string, override = false): Promise<HostConfig | null> {
205
+ // First get current config to preserve all other settings
206
+ const currentConfig = await this.getHostConfig()
207
+
208
+ // Only update if no password is set OR override is requested
209
+ if (currentConfig.password && !override) {
210
+ return null // Skip - password already configured
211
+ }
212
+
213
+ // Update with authentication settings (id must be in body, not URL)
214
+ const updatedConfig: HostConfig = {
215
+ ...currentConfig,
216
+ authenticationMethod: "forms",
217
+ authenticationRequired: "enabled",
218
+ username,
219
+ password,
220
+ passwordConfirmation: password,
221
+ }
222
+
223
+ // PUT to /config/host with id in body (not /config/host/{id})
224
+ return this.request<HostConfig>("/config/host", {
225
+ method: "PUT",
226
+ body: JSON.stringify(updatedConfig),
227
+ })
228
+ }
229
+ }
230
+
231
+ // Types for Host Config API
232
+ export interface HostConfig {
233
+ id: number
234
+ bindAddress: string | null
235
+ port: number
236
+ sslPort: number
237
+ enableSsl: boolean
238
+ launchBrowser: boolean
239
+ authenticationMethod: "none" | "basic" | "forms" | "external"
240
+ authenticationRequired: "enabled" | "disabledForLocalAddresses"
241
+ analyticsEnabled: boolean
242
+ username: string | null
243
+ password: string | null
244
+ passwordConfirmation: string | null
245
+ logLevel: string | null
246
+ logSizeLimit: number
247
+ consoleLogLevel: string | null
248
+ branch: string | null
249
+ apiKey: string | null
250
+ sslCertPath: string | null
251
+ sslCertPassword: string | null
252
+ urlBase: string | null
253
+ instanceName: string | null
254
+ applicationUrl: string | null
255
+ updateAutomatically: boolean
256
+ updateMechanism: string
257
+ updateScriptPath: string | null
258
+ proxyEnabled: boolean
259
+ proxyType: string
260
+ proxyHostname: string | null
261
+ proxyPort: number
262
+ proxyUsername: string | null
263
+ proxyPassword: string | null
264
+ proxyBypassFilter: string | null
265
+ proxyBypassLocalAddresses: boolean
266
+ certificateValidation: string
267
+ backupFolder: string | null
268
+ backupInterval: number
269
+ backupRetention: number
270
+ trustCgnatIpAddresses: boolean
198
271
  }
@@ -109,7 +109,8 @@ export const APPS: Record<AppId, AppDefinition> = {
109
109
  image: "lscr.io/linuxserver/bazarr:latest",
110
110
  puid: 13013,
111
111
  pgid: 13000,
112
- volumes: (root) => [`${root}/config/bazarr:/config`, `${root}/data/media:/media`],
112
+ // TRaSH: Bazarr only needs media access, use /data/media for consistent paths
113
+ volumes: (root) => [`${root}/config/bazarr:/config`, `${root}/data/media:/data/media`],
113
114
  dependsOn: ["sonarr", "radarr"],
114
115
  trashGuide: "docs/Bazarr/",
115
116
  apiKeyMeta: {
@@ -279,7 +280,8 @@ export const APPS: Record<AppId, AppDefinition> = {
279
280
  image: "lscr.io/linuxserver/plex:latest",
280
281
  puid: 13010,
281
282
  pgid: 13000,
282
- volumes: (root) => [`${root}/config/plex:/config`, `${root}/data/media:/media`],
283
+ // TRaSH: Media servers only need media access, use /data/media for consistent paths
284
+ volumes: (root) => [`${root}/config/plex:/config`, `${root}/data/media:/data/media`],
283
285
  environment: { VERSION: "docker" },
284
286
  trashGuide: "docs/Plex/",
285
287
  apiKeyMeta: {
@@ -31,11 +31,13 @@ export async function ensureDirectoryStructure(config: EasiarrConfig): Promise<v
31
31
 
32
32
  for (const [appId, contentType] of Object.entries(CONTENT_TYPE_MAP)) {
33
33
  if (enabledApps.has(appId as AppId)) {
34
- // Create this content type in ALL base dirs to follow TRaSH standard
35
- // (e.g. data/torrents/movies, data/usenet/movies, data/media/movies)
36
- for (const base of BASE_DIRS) {
37
- await mkdir(join(dataRoot, base, contentType), { recursive: true })
38
- }
34
+ // TRaSH Structure:
35
+ // - Torrents: flat categories (data/torrents/movies, etc.)
36
+ // - Usenet: categories inside complete (data/usenet/complete/movies, etc.)
37
+ // - Media: flat library folders (data/media/movies, etc.)
38
+ await mkdir(join(dataRoot, "torrents", contentType), { recursive: true })
39
+ await mkdir(join(dataRoot, "usenet", "complete", contentType), { recursive: true })
40
+ await mkdir(join(dataRoot, "media", contentType), { recursive: true })
39
41
  }
40
42
  }
41
43
 
@@ -44,16 +46,16 @@ export async function ensureDirectoryStructure(config: EasiarrConfig): Promise<v
44
46
  // Always create 'photos' in media (Personal photos)
45
47
  await mkdir(join(dataRoot, "media", "photos"), { recursive: true })
46
48
 
47
- // Always create 'console' and 'software' in torrents/usenet (Manual DL categories)
49
+ // TRaSH: Usenet has incomplete/complete structure, torrents do NOT
50
+ await mkdir(join(dataRoot, "usenet", "incomplete"), { recursive: true })
51
+ await mkdir(join(dataRoot, "usenet", "complete"), { recursive: true })
52
+
53
+ // Manual download categories for both torrent and usenet
48
54
  for (const base of ["torrents", "usenet"]) {
49
55
  await mkdir(join(dataRoot, base, "console"), { recursive: true })
50
56
  await mkdir(join(dataRoot, base, "software"), { recursive: true })
51
57
  // Create 'watch' folder for manual .torrent/.nzb drops
52
58
  await mkdir(join(dataRoot, base, "watch"), { recursive: true })
53
-
54
- // 'complete' and 'incomplete' default folders
55
- await mkdir(join(dataRoot, base, "complete"), { recursive: true })
56
- await mkdir(join(dataRoot, base, "incomplete"), { recursive: true })
57
59
  }
58
60
 
59
61
  if (enabledApps.has("prowlarr")) {
@@ -27,17 +27,22 @@ interface ConfigResult {
27
27
  message?: string
28
28
  }
29
29
 
30
- type Step = "configure" | "qbittorrent" | "sabnzbd" | "done"
30
+ type Step = "credentials" | "configure" | "qbittorrent" | "sabnzbd" | "done"
31
31
 
32
32
  export class AppConfigurator extends BoxRenderable {
33
33
  private config: EasiarrConfig
34
34
  private cliRenderer: CliRenderer
35
35
  private keyHandler!: (key: KeyEvent) => void
36
36
  private results: ConfigResult[] = []
37
- private currentStep: Step = "configure"
37
+ private currentStep: Step = "credentials"
38
38
  private contentBox!: BoxRenderable
39
39
  private pageContainer!: BoxRenderable
40
40
 
41
+ // Global *arr credentials
42
+ private globalUsername = "admin"
43
+ private globalPassword = ""
44
+ private overrideExisting = false
45
+
41
46
  // Download client credentials
42
47
  private qbHost = "qbittorrent"
43
48
  private qbPort = 8080
@@ -73,7 +78,8 @@ export class AppConfigurator extends BoxRenderable {
73
78
  // Load saved credentials from .env
74
79
  this.loadSavedCredentials()
75
80
 
76
- this.runConfiguration()
81
+ // Start with credentials prompt
82
+ this.renderCredentialsPrompt()
77
83
  }
78
84
 
79
85
  private loadSavedCredentials() {
@@ -86,7 +92,11 @@ export class AppConfigurator extends BoxRenderable {
86
92
  const [key, ...val] = line.split("=")
87
93
  if (key && val.length > 0) {
88
94
  const value = val.join("=").trim()
89
- if (key.trim() === "QBITTORRENT_PASSWORD") {
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") {
90
100
  this.qbPass = value
91
101
  } else if (key.trim() === "SABNZBD_API_KEY") {
92
102
  this.sabApiKey = value
@@ -98,6 +108,143 @@ export class AppConfigurator extends BoxRenderable {
98
108
  }
99
109
  }
100
110
 
111
+ private renderCredentialsPrompt() {
112
+ this.clear()
113
+
114
+ const { container, content } = createPageLayout(this.cliRenderer, {
115
+ title: "Configure Apps",
116
+ stepInfo: "Global Credentials",
117
+ footerHint: "Enter credentials for all *arr apps Tab Next Field Enter Continue Esc Skip",
118
+ })
119
+ this.pageContainer = container
120
+ this.add(container)
121
+
122
+ content.add(
123
+ new TextRenderable(this.cliRenderer, {
124
+ content: "Set a global username/password for all *arr applications:\n",
125
+ fg: "#4a9eff",
126
+ })
127
+ )
128
+
129
+ // Username input
130
+ content.add(new TextRenderable(this.cliRenderer, { content: "Username:", fg: "#aaaaaa" }))
131
+ const userInput = new InputRenderable(this.cliRenderer, {
132
+ id: "global-user-input",
133
+ width: 30,
134
+ placeholder: "admin",
135
+ value: this.globalUsername,
136
+ focusedBackgroundColor: "#1a1a1a",
137
+ })
138
+ content.add(userInput)
139
+
140
+ content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
141
+
142
+ // Password input
143
+ content.add(new TextRenderable(this.cliRenderer, { content: "Password:", fg: "#aaaaaa" }))
144
+ const passInput = new InputRenderable(this.cliRenderer, {
145
+ id: "global-pass-input",
146
+ width: 30,
147
+ placeholder: "Enter password",
148
+ value: this.globalPassword,
149
+ focusedBackgroundColor: "#1a1a1a",
150
+ })
151
+ content.add(passInput)
152
+
153
+ content.add(new BoxRenderable(this.cliRenderer, { width: 1, height: 1 })) // Spacer
154
+
155
+ // Override toggle
156
+ const overrideText = new TextRenderable(this.cliRenderer, {
157
+ id: "override-toggle",
158
+ content: `[O] Override existing: ${this.overrideExisting ? "Yes" : "No"}`,
159
+ fg: this.overrideExisting ? "#50fa7b" : "#6272a4",
160
+ })
161
+ content.add(overrideText)
162
+
163
+ userInput.focus()
164
+ let focusedInput: InputRenderable | null = userInput
165
+
166
+ // Handle key events
167
+ this.keyHandler = (key: KeyEvent) => {
168
+ // Skip shortcut keys when an input is focused (allow typing 'o')
169
+ const inputIsFocused = focusedInput !== null
170
+
171
+ if (key.name === "o" && !inputIsFocused) {
172
+ // Toggle override only when no input is focused
173
+ this.overrideExisting = !this.overrideExisting
174
+ overrideText.content = `[O] Override existing: ${this.overrideExisting ? "Yes" : "No"}`
175
+ overrideText.fg = this.overrideExisting ? "#50fa7b" : "#6272a4"
176
+ } else if (key.name === "tab") {
177
+ // Toggle focus between inputs
178
+ if (focusedInput === userInput) {
179
+ userInput.blur()
180
+ passInput.focus()
181
+ focusedInput = passInput
182
+ } else {
183
+ passInput.blur()
184
+ userInput.focus()
185
+ focusedInput = userInput
186
+ }
187
+ } else if (key.name === "escape") {
188
+ // Skip credentials setup
189
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
190
+ userInput.blur()
191
+ passInput.blur()
192
+ focusedInput = null
193
+ this.currentStep = "configure"
194
+ this.runConfiguration()
195
+ } else if (key.name === "return") {
196
+ // Save and continue
197
+ this.globalUsername = userInput.value || "admin"
198
+ this.globalPassword = passInput.value
199
+
200
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
201
+ userInput.blur()
202
+ passInput.blur()
203
+ focusedInput = null
204
+
205
+ // Save credentials to .env
206
+ this.saveGlobalCredentialsToEnv()
207
+
208
+ this.currentStep = "configure"
209
+ this.runConfiguration()
210
+ }
211
+ }
212
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
213
+ }
214
+
215
+ private async saveGlobalCredentialsToEnv() {
216
+ 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")
243
+ } catch {
244
+ // Ignore errors - not critical
245
+ }
246
+ }
247
+
101
248
  private async runConfiguration() {
102
249
  // Initialize results for apps that have rootFolder
103
250
  for (const appConfig of this.config.apps) {
@@ -184,6 +331,15 @@ export class AppConfigurator extends BoxRenderable {
184
331
 
185
332
  // Add root folder
186
333
  await client.addRootFolder(appDef.rootFolder.path)
334
+
335
+ // Set up authentication if credentials provided
336
+ if (this.globalPassword) {
337
+ try {
338
+ await client.updateHostConfig(this.globalUsername, this.globalPassword, this.overrideExisting)
339
+ } catch {
340
+ // Ignore auth setup errors - not critical
341
+ }
342
+ }
187
343
  }
188
344
 
189
345
  private extractApiKey(appId: AppId): string | null {