@muhammedaksam/easiarr 0.1.7 → 0.1.8

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.8",
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,76 @@ 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
214
+ const updatedConfig = {
215
+ ...currentConfig,
216
+ authenticationMethod: "forms",
217
+ authenticationRequired: "enabled",
218
+ username,
219
+ password,
220
+ passwordConfirmation: password,
221
+ }
222
+
223
+ return this.request<HostConfig>(`/config/host/${currentConfig.id}`, {
224
+ method: "PUT",
225
+ body: JSON.stringify(updatedConfig),
226
+ })
227
+ }
228
+ }
229
+
230
+ // Types for Host Config API
231
+ export interface HostConfig {
232
+ id: number
233
+ bindAddress: string | null
234
+ port: number
235
+ sslPort: number
236
+ enableSsl: boolean
237
+ launchBrowser: boolean
238
+ authenticationMethod: "none" | "basic" | "forms" | "external"
239
+ authenticationRequired: "enabled" | "disabledForLocalAddresses"
240
+ analyticsEnabled: boolean
241
+ username: string | null
242
+ password: string | null
243
+ passwordConfirmation: string | null
244
+ logLevel: string | null
245
+ logSizeLimit: number
246
+ consoleLogLevel: string | null
247
+ branch: string | null
248
+ apiKey: string | null
249
+ sslCertPath: string | null
250
+ sslCertPassword: string | null
251
+ urlBase: string | null
252
+ instanceName: string | null
253
+ applicationUrl: string | null
254
+ updateAutomatically: boolean
255
+ updateMechanism: string
256
+ updateScriptPath: string | null
257
+ proxyEnabled: boolean
258
+ proxyType: string
259
+ proxyHostname: string | null
260
+ proxyPort: number
261
+ proxyUsername: string | null
262
+ proxyPassword: string | null
263
+ proxyBypassFilter: string | null
264
+ proxyBypassLocalAddresses: boolean
265
+ certificateValidation: string
266
+ backupFolder: string | null
267
+ backupInterval: number
268
+ backupRetention: number
269
+ trustCgnatIpAddresses: boolean
198
270
  }
@@ -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,138 @@ 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 = userInput
165
+
166
+ // Handle key events
167
+ this.keyHandler = (key: KeyEvent) => {
168
+ if (key.name === "o" && !userInput.focused && !passInput.focused) {
169
+ // Toggle override
170
+ this.overrideExisting = !this.overrideExisting
171
+ overrideText.content = `[O] Override existing: ${this.overrideExisting ? "Yes" : "No"}`
172
+ overrideText.fg = this.overrideExisting ? "#50fa7b" : "#6272a4"
173
+ } else if (key.name === "tab") {
174
+ // Toggle focus between inputs
175
+ if (focusedInput === userInput) {
176
+ userInput.blur()
177
+ passInput.focus()
178
+ focusedInput = passInput
179
+ } else {
180
+ passInput.blur()
181
+ userInput.focus()
182
+ focusedInput = userInput
183
+ }
184
+ } else if (key.name === "escape") {
185
+ // Skip credentials setup
186
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
187
+ userInput.blur()
188
+ passInput.blur()
189
+ this.currentStep = "configure"
190
+ this.runConfiguration()
191
+ } else if (key.name === "return") {
192
+ // Save and continue
193
+ this.globalUsername = userInput.value || "admin"
194
+ this.globalPassword = passInput.value
195
+
196
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
197
+ userInput.blur()
198
+ passInput.blur()
199
+
200
+ // Save credentials to .env
201
+ this.saveGlobalCredentialsToEnv()
202
+
203
+ this.currentStep = "configure"
204
+ this.runConfiguration()
205
+ }
206
+ }
207
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
208
+ }
209
+
210
+ private async saveGlobalCredentialsToEnv() {
211
+ try {
212
+ const envPath = getComposePath().replace("docker-compose.yml", ".env")
213
+
214
+ // Read existing .env if present
215
+ const currentEnv: Record<string, string> = {}
216
+ if (existsSync(envPath)) {
217
+ const content = await readFile(envPath, "utf-8")
218
+ content.split("\n").forEach((line) => {
219
+ const [key, ...val] = line.split("=")
220
+ if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
221
+ })
222
+ }
223
+
224
+ // Add global credentials
225
+ if (this.globalUsername) {
226
+ currentEnv["GLOBAL_USERNAME"] = this.globalUsername
227
+ }
228
+ if (this.globalPassword) {
229
+ currentEnv["GLOBAL_PASSWORD"] = this.globalPassword
230
+ }
231
+
232
+ // Reconstruct .env content
233
+ const envContent = Object.entries(currentEnv)
234
+ .map(([k, v]) => `${k}=${v}`)
235
+ .join("\n")
236
+
237
+ await writeFile(envPath, envContent, "utf-8")
238
+ } catch {
239
+ // Ignore errors - not critical
240
+ }
241
+ }
242
+
101
243
  private async runConfiguration() {
102
244
  // Initialize results for apps that have rootFolder
103
245
  for (const appConfig of this.config.apps) {
@@ -184,6 +326,15 @@ export class AppConfigurator extends BoxRenderable {
184
326
 
185
327
  // Add root folder
186
328
  await client.addRootFolder(appDef.rootFolder.path)
329
+
330
+ // Set up authentication if credentials provided
331
+ if (this.globalPassword) {
332
+ try {
333
+ await client.updateHostConfig(this.globalUsername, this.globalPassword, this.overrideExisting)
334
+ } catch {
335
+ // Ignore auth setup errors - not critical
336
+ }
337
+ }
187
338
  }
188
339
 
189
340
  private extractApiKey(appId: AppId): string | null {