@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 +1 -1
- package/src/api/arr-api.ts +26 -34
- package/src/api/qbittorrent-api.ts +198 -0
- package/src/apps/registry.ts +4 -2
- package/src/compose/generator.ts +10 -28
- package/src/ui/screens/AdvancedSettings.ts +2 -1
- package/src/ui/screens/ApiKeyViewer.ts +5 -23
- package/src/ui/screens/AppConfigurator.ts +48 -98
- package/src/ui/screens/SecretsEditor.ts +9 -50
- package/src/utils/categories.ts +59 -0
- package/src/utils/env.ts +98 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.
|
|
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",
|
package/src/api/arr-api.ts
CHANGED
|
@@ -26,42 +26,16 @@ export interface DownloadClient extends DownloadClientConfig {
|
|
|
26
26
|
id?: number
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
+
}
|
package/src/apps/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: {
|
package/src/compose/generator.ts
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* Generates docker-compose.yml from Easiarr configuration
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { writeFile
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
currentEnv[envKey] = k.key
|
|
342
|
+
updates[`API_KEY_${k.appId.toUpperCase()}`] = k.key
|
|
356
343
|
}
|
|
357
344
|
|
|
358
|
-
|
|
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 {
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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: "
|
|
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
|
-
//
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
560
|
+
updates.QBITTORRENT_PASSWORD = this.qbPass
|
|
605
561
|
} else if (type === "sabnzbd" && this.sabApiKey) {
|
|
606
|
-
|
|
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 {
|
|
14
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
+
updates[key] = input.value
|
|
224
187
|
})
|
|
225
188
|
|
|
226
189
|
// Ensure directory exists
|
|
227
190
|
try {
|
|
228
|
-
await mkdir(dirname(
|
|
191
|
+
await mkdir(dirname(getEnvPath()), { recursive: true })
|
|
229
192
|
} catch {
|
|
230
193
|
// Ignore if exists
|
|
231
194
|
}
|
|
232
195
|
|
|
233
|
-
//
|
|
234
|
-
|
|
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
|
+
}
|
package/src/utils/env.ts
ADDED
|
@@ -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
|
+
}
|