@muhammedaksam/easiarr 0.2.0 → 0.3.1
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 +27 -2
- package/src/api/custom-format-api.ts +212 -0
- package/src/api/prowlarr-api.ts +301 -0
- package/src/api/quality-profile-api.ts +205 -0
- package/src/apps/registry.ts +2 -4
- package/src/data/trash-profiles.ts +252 -0
- package/src/ui/screens/AppConfigurator.ts +34 -2
- package/src/ui/screens/MainMenu.ts +33 -1
- package/src/ui/screens/ProwlarrSetup.ts +378 -0
- package/src/ui/screens/TRaSHProfileSetup.ts +371 -0
- package/src/utils/debug.ts +27 -0
|
@@ -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,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
|
+
}
|