@muhammedaksam/easiarr 0.1.10 → 0.3.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.
@@ -0,0 +1,371 @@
1
+ /**
2
+ * TRaSH Profile Setup Screen
3
+ * Allows users to configure TRaSH-recommended quality profiles and custom formats
4
+ */
5
+
6
+ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
7
+ import { createPageLayout } from "../components/PageLayout"
8
+ import { EasiarrConfig, AppId } from "../../config/schema"
9
+ import { getApp } from "../../apps/registry"
10
+ import { QualityProfileClient } from "../../api/quality-profile-api"
11
+ import { CustomFormatClient, getCFNamesForCategories } from "../../api/custom-format-api"
12
+ import { getPresetsForApp, TRaSHProfilePreset } from "../../data/trash-profiles"
13
+ import { readEnvSync } from "../../utils/env"
14
+ import { debugLog } from "../../utils/debug"
15
+
16
+ interface SetupResult {
17
+ appId: AppId
18
+ appName: string
19
+ profile: string
20
+ cfCount: number
21
+ status: "pending" | "configuring" | "success" | "error"
22
+ message?: string
23
+ }
24
+
25
+ type Step = "select-apps" | "select-profiles" | "importing" | "done"
26
+
27
+ export class TRaSHProfileSetup extends BoxRenderable {
28
+ private config: EasiarrConfig
29
+ private cliRenderer: CliRenderer
30
+ private onBack: () => void
31
+ private keyHandler!: (key: KeyEvent) => void
32
+ private results: SetupResult[] = []
33
+ private currentStep: Step = "select-apps"
34
+ private contentBox!: BoxRenderable
35
+ private pageContainer!: BoxRenderable
36
+
37
+ // Selected apps and profiles
38
+ private selectedApps: Map<AppId, boolean> = new Map()
39
+ private selectedProfiles: Map<AppId, string> = new Map()
40
+ private currentIndex = 0
41
+ private availableApps: AppId[] = []
42
+
43
+ constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
44
+ const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
45
+ title: "TRaSH Guide Setup",
46
+ stepInfo: "Configure quality profiles and custom formats",
47
+ footerHint: "↑↓ Navigate Space Select Enter Confirm Esc Back",
48
+ })
49
+ super(cliRenderer, { width: "100%", height: "100%" })
50
+ this.add(pageContainer)
51
+
52
+ this.config = config
53
+ this.cliRenderer = cliRenderer
54
+ this.onBack = onBack
55
+ this.contentBox = contentBox
56
+ this.pageContainer = pageContainer
57
+
58
+ // Get enabled *arr apps that support quality profiles
59
+ this.availableApps = config.apps.filter((a) => a.enabled && ["radarr", "sonarr"].includes(a.id)).map((a) => a.id)
60
+
61
+ // Initialize selections
62
+ this.availableApps.forEach((id) => {
63
+ this.selectedApps.set(id, true)
64
+ const presets = getPresetsForApp(id as "radarr" | "sonarr")
65
+ if (presets.length > 0) {
66
+ this.selectedProfiles.set(id, presets[0].id)
67
+ }
68
+ })
69
+
70
+ this.initKeyHandler()
71
+ this.refreshContent()
72
+ }
73
+
74
+ private initKeyHandler(): void {
75
+ this.keyHandler = (key: KeyEvent) => {
76
+ debugLog(
77
+ "TRaSH",
78
+ `Key pressed: name=${key.name}, ctrl=${key.ctrl}, step=${this.currentStep}, index=${this.currentIndex}`
79
+ )
80
+
81
+ if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
82
+ this.cleanup()
83
+ return
84
+ }
85
+
86
+ switch (this.currentStep) {
87
+ case "select-apps":
88
+ this.handleSelectAppsKeys(key)
89
+ break
90
+ case "select-profiles":
91
+ this.handleSelectProfilesKeys(key)
92
+ break
93
+ case "done":
94
+ if (key.name === "return" || key.name === "escape") {
95
+ this.cleanup()
96
+ }
97
+ break
98
+ }
99
+ }
100
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
101
+ debugLog("TRaSH", `Key handler registered, availableApps=${this.availableApps.join(",")}`)
102
+ }
103
+
104
+ private handleSelectAppsKeys(key: KeyEvent): void {
105
+ const apps = this.availableApps
106
+
107
+ if (key.name === "up" && this.currentIndex > 0) {
108
+ debugLog("TRaSH", `Moving up from ${this.currentIndex} to ${this.currentIndex - 1}`)
109
+ this.currentIndex--
110
+ this.refreshContent()
111
+ } else if (key.name === "down" && this.currentIndex < apps.length - 1) {
112
+ debugLog("TRaSH", `Moving down from ${this.currentIndex} to ${this.currentIndex + 1}, apps.length=${apps.length}`)
113
+ this.currentIndex++
114
+ this.refreshContent()
115
+ } else if (key.name === "space") {
116
+ const app = apps[this.currentIndex]
117
+ this.selectedApps.set(app, !this.selectedApps.get(app))
118
+ this.refreshContent()
119
+ } else if (key.name === "return") {
120
+ const hasSelected = Array.from(this.selectedApps.values()).some((v) => v)
121
+ if (hasSelected) {
122
+ this.currentStep = "select-profiles"
123
+ this.currentIndex = 0
124
+ this.refreshContent()
125
+ }
126
+ }
127
+ }
128
+
129
+ private handleSelectProfilesKeys(key: KeyEvent): void {
130
+ const selectedAppIds = this.availableApps.filter((id) => this.selectedApps.get(id))
131
+ const app = selectedAppIds[this.currentIndex]
132
+ const presets = getPresetsForApp(app as "radarr" | "sonarr")
133
+
134
+ if (key.name === "up") {
135
+ const current = this.selectedProfiles.get(app)
136
+ const idx = presets.findIndex((p) => p.id === current)
137
+ if (idx > 0) {
138
+ this.selectedProfiles.set(app, presets[idx - 1].id)
139
+ this.refreshContent()
140
+ }
141
+ } else if (key.name === "down") {
142
+ const current = this.selectedProfiles.get(app)
143
+ const idx = presets.findIndex((p) => p.id === current)
144
+ if (idx < presets.length - 1) {
145
+ this.selectedProfiles.set(app, presets[idx + 1].id)
146
+ this.refreshContent()
147
+ }
148
+ } else if (key.name === "tab" || key.name === "right") {
149
+ if (this.currentIndex < selectedAppIds.length - 1) {
150
+ this.currentIndex++
151
+ this.refreshContent()
152
+ }
153
+ } else if (key.name === "left" && this.currentIndex > 0) {
154
+ this.currentIndex--
155
+ this.refreshContent()
156
+ } else if (key.name === "return") {
157
+ this.startImport()
158
+ } else if (key.name === "backspace" || key.name === "b") {
159
+ this.currentStep = "select-apps"
160
+ this.currentIndex = 0
161
+ this.refreshContent()
162
+ }
163
+ }
164
+
165
+ private async startImport(): Promise<void> {
166
+ this.currentStep = "importing"
167
+ this.results = []
168
+
169
+ const selectedAppIds = this.availableApps.filter((id) => this.selectedApps.get(id))
170
+
171
+ for (const appId of selectedAppIds) {
172
+ const appDef = getApp(appId)
173
+ const profileId = this.selectedProfiles.get(appId)
174
+ const preset = getPresetsForApp(appId as "radarr" | "sonarr").find((p) => p.id === profileId)
175
+
176
+ if (!appDef || !preset) continue
177
+
178
+ this.results.push({
179
+ appId,
180
+ appName: appDef.name,
181
+ profile: preset.name,
182
+ cfCount: 0,
183
+ status: "configuring",
184
+ })
185
+ this.refreshContent()
186
+
187
+ try {
188
+ await this.configureApp(appId, preset)
189
+ const result = this.results.find((r) => r.appId === appId)
190
+ if (result) {
191
+ result.status = "success"
192
+ result.cfCount = Object.keys(preset.cfScores).length
193
+ }
194
+ } catch (error) {
195
+ const result = this.results.find((r) => r.appId === appId)
196
+ if (result) {
197
+ result.status = "error"
198
+ result.message = error instanceof Error ? error.message : "Unknown error"
199
+ }
200
+ }
201
+ this.refreshContent()
202
+ }
203
+
204
+ this.currentStep = "done"
205
+ this.refreshContent()
206
+ }
207
+
208
+ private async configureApp(appId: AppId, preset: TRaSHProfilePreset): Promise<void> {
209
+ const appDef = getApp(appId)
210
+ if (!appDef) throw new Error("App not found")
211
+
212
+ const env = readEnvSync()
213
+ const apiKey = env[`API_KEY_${appId.toUpperCase()}`]
214
+ if (!apiKey) throw new Error("API key not found - run Extract API Keys first")
215
+
216
+ const port = this.config.apps.find((a) => a.id === appId)?.port || appDef.defaultPort
217
+ const qpClient = new QualityProfileClient("localhost", port, apiKey)
218
+ const cfClient = new CustomFormatClient("localhost", port, apiKey)
219
+
220
+ // Import Custom Formats first
221
+ const cfCategories = ["unwanted", "misc"]
222
+ if (preset.id.includes("uhd") || preset.id.includes("2160")) {
223
+ cfCategories.push("hdr")
224
+ }
225
+ if (preset.id.includes("remux")) {
226
+ cfCategories.push("audio")
227
+ }
228
+
229
+ const cfNames = getCFNamesForCategories(appId as "radarr" | "sonarr", cfCategories)
230
+ const { cfs } = await CustomFormatClient.fetchTRaSHCustomFormats(appId as "radarr" | "sonarr", cfNames)
231
+ await cfClient.importCustomFormats(cfs)
232
+
233
+ // Create quality profile
234
+ await qpClient.createTRaSHProfile(preset.name, preset.cutoffQuality, preset.allowedQualities, preset.cfScores)
235
+ }
236
+
237
+ private refreshContent(): void {
238
+ // Clear content
239
+ this.contentBox.getChildren().forEach((child) => child.destroy())
240
+
241
+ switch (this.currentStep) {
242
+ case "select-apps":
243
+ this.renderSelectApps()
244
+ break
245
+ case "select-profiles":
246
+ this.renderSelectProfiles()
247
+ break
248
+ case "importing":
249
+ case "done":
250
+ this.renderResults()
251
+ break
252
+ }
253
+ }
254
+
255
+ private renderSelectApps(): void {
256
+ this.contentBox.add(
257
+ new TextRenderable(this.cliRenderer, {
258
+ content: "Select apps to configure with TRaSH profiles:\n(Space to toggle, Enter to continue)\n\n",
259
+ fg: "#aaaaaa",
260
+ })
261
+ )
262
+
263
+ this.availableApps.forEach((appId, idx) => {
264
+ const app = getApp(appId)
265
+ const selected = this.selectedApps.get(appId)
266
+ const pointer = idx === this.currentIndex ? "→ " : " "
267
+ const check = selected ? "[✓]" : "[ ]"
268
+ const fg = idx === this.currentIndex ? "#50fa7b" : selected ? "#8be9fd" : "#6272a4"
269
+
270
+ this.contentBox.add(
271
+ new TextRenderable(this.cliRenderer, {
272
+ content: `${pointer}${check} ${app?.name || appId}\n`,
273
+ fg,
274
+ })
275
+ )
276
+ })
277
+ }
278
+
279
+ private renderSelectProfiles(): void {
280
+ const selectedAppIds = this.availableApps.filter((id) => this.selectedApps.get(id))
281
+
282
+ this.contentBox.add(
283
+ new TextRenderable(this.cliRenderer, {
284
+ content: "Select quality profile for each app:\n(↑↓ change profile, Tab next app, Enter apply)\n\n",
285
+ fg: "#aaaaaa",
286
+ })
287
+ )
288
+
289
+ selectedAppIds.forEach((appId, appIdx) => {
290
+ const app = getApp(appId)
291
+ const presets = getPresetsForApp(appId as "radarr" | "sonarr")
292
+ const selectedPresetId = this.selectedProfiles.get(appId)
293
+ const isCurrent = appIdx === this.currentIndex
294
+
295
+ this.contentBox.add(
296
+ new TextRenderable(this.cliRenderer, {
297
+ content: `${isCurrent ? "→ " : " "}${app?.name}:\n`,
298
+ fg: isCurrent ? "#50fa7b" : "#8be9fd",
299
+ })
300
+ )
301
+
302
+ presets.forEach((preset) => {
303
+ const isSelected = preset.id === selectedPresetId
304
+ const bullet = isSelected ? "●" : "○"
305
+ this.contentBox.add(
306
+ new TextRenderable(this.cliRenderer, {
307
+ content: ` ${bullet} ${preset.name}\n`,
308
+ fg: isSelected ? "#f1fa8c" : "#6272a4",
309
+ })
310
+ )
311
+ })
312
+ })
313
+ }
314
+
315
+ private renderResults(): void {
316
+ const headerText = this.currentStep === "done" ? "✓ Configuration Complete!\n\n" : "Configuring...\n\n"
317
+ this.contentBox.add(
318
+ new TextRenderable(this.cliRenderer, {
319
+ content: headerText,
320
+ fg: this.currentStep === "done" ? "#50fa7b" : "#f1fa8c",
321
+ })
322
+ )
323
+
324
+ for (const result of this.results) {
325
+ let status = ""
326
+ let fg = "#aaaaaa"
327
+ switch (result.status) {
328
+ case "pending":
329
+ status = "⏳"
330
+ break
331
+ case "configuring":
332
+ status = "🔄"
333
+ fg = "#f1fa8c"
334
+ break
335
+ case "success":
336
+ status = "✓"
337
+ fg = "#50fa7b"
338
+ break
339
+ case "error":
340
+ status = "✗"
341
+ fg = "#ff5555"
342
+ break
343
+ }
344
+
345
+ let content = `${status} ${result.appName}: ${result.profile}`
346
+ if (result.status === "success") {
347
+ content += ` (${result.cfCount} CF scores)`
348
+ }
349
+ if (result.message) {
350
+ content += ` - ${result.message}`
351
+ }
352
+
353
+ this.contentBox.add(new TextRenderable(this.cliRenderer, { content: content + "\n", fg }))
354
+ }
355
+
356
+ if (this.currentStep === "done") {
357
+ this.contentBox.add(
358
+ new TextRenderable(this.cliRenderer, {
359
+ content: "\nPress Enter or Esc to continue...",
360
+ fg: "#6272a4",
361
+ })
362
+ )
363
+ }
364
+ }
365
+
366
+ private cleanup(): void {
367
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
368
+ this.destroy()
369
+ this.onBack()
370
+ }
371
+ }
@@ -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,27 @@
1
+ /**
2
+ * Debug logging utility for Easiarr
3
+ *
4
+ * Only logs when EASIARR_DEBUG environment variable is set.
5
+ * Usage: EASIARR_DEBUG=1 bun run dev
6
+ */
7
+
8
+ import { appendFileSync } from "fs"
9
+ import { join } from "path"
10
+
11
+ const DEBUG_ENABLED = process.env.EASIARR_DEBUG === "1" || process.env.EASIARR_DEBUG === "true"
12
+ const logFile = join(import.meta.dir, "..", "..", "debug.log")
13
+
14
+ /**
15
+ * Log a debug message to debug.log file if EASIARR_DEBUG is enabled
16
+ */
17
+ export function debugLog(category: string, message: string): void {
18
+ if (!DEBUG_ENABLED) return
19
+
20
+ const timestamp = new Date().toISOString()
21
+ const line = `[${timestamp}] [${category}] ${message}\n`
22
+ try {
23
+ appendFileSync(logFile, line)
24
+ } catch {
25
+ // Ignore logging errors
26
+ }
27
+ }
@@ -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
+ }