@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 +1 -1
- package/src/api/arr-api.ts +74 -1
- package/src/apps/registry.ts +4 -2
- package/src/structure/manager.ts +12 -10
- package/src/ui/screens/AppConfigurator.ts +160 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/api/arr-api.ts
CHANGED
|
@@ -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
|
}
|
package/src/apps/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: {
|
package/src/structure/manager.ts
CHANGED
|
@@ -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
|
-
//
|
|
35
|
-
// (
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
//
|
|
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 = "
|
|
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
|
-
|
|
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() === "
|
|
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 {
|