@muhammedaksam/easiarr 1.1.8 → 1.2.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/README.md +9 -2
- package/package.json +4 -4
- package/src/api/profilarr-api.ts +284 -0
- package/src/apps/registry.ts +92 -0
- package/src/compose/caddy-config.ts +129 -0
- package/src/compose/generator.ts +10 -1
- package/src/config/recyclarr-config.ts +179 -0
- package/src/config/schema.ts +19 -0
- package/src/docker/client.ts +16 -0
- package/src/structure/manager.ts +41 -1
- package/src/ui/screens/FullAutoSetup.ts +123 -1
- package/src/ui/screens/LogsViewer.ts +468 -0
- package/src/ui/screens/MainMenu.ts +14 -0
- package/src/ui/screens/QuickSetup.ts +52 -3
- package/src/ui/screens/RecyclarrSetup.ts +418 -0
- package/src/ui/screens/SettingsScreen.ts +35 -1
- package/src/utils/logs.ts +118 -0
- package/src/utils/unraid.ts +101 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recyclarr Setup Screen
|
|
3
|
+
* Configure TRaSH Guides profile sync and trigger manual runs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CliRenderer, KeyEvent } from "@opentui/core"
|
|
7
|
+
import { BoxRenderable, TextRenderable, SelectRenderable, SelectRenderableEvents } from "@opentui/core"
|
|
8
|
+
import type { EasiarrConfig } from "../../config/schema"
|
|
9
|
+
import { saveRecyclarrConfig } from "../../config/recyclarr-config"
|
|
10
|
+
import { createPageLayout } from "../components/PageLayout"
|
|
11
|
+
import { composeRun } from "../../docker"
|
|
12
|
+
import { RADARR_PRESETS, SONARR_PRESETS } from "../../data/trash-profiles"
|
|
13
|
+
|
|
14
|
+
type ViewMode = "main" | "radarr" | "sonarr" | "sync"
|
|
15
|
+
|
|
16
|
+
export class RecyclarrSetup extends BoxRenderable {
|
|
17
|
+
private cliRenderer: CliRenderer
|
|
18
|
+
private config: EasiarrConfig
|
|
19
|
+
private onBack: () => void
|
|
20
|
+
private keyHandler: ((key: KeyEvent) => void) | null = null
|
|
21
|
+
private mode: ViewMode = "main"
|
|
22
|
+
|
|
23
|
+
// Selected profiles
|
|
24
|
+
private radarrPreset: string = "hd-bluray-web"
|
|
25
|
+
private sonarrPreset: string = "web-1080p-v4"
|
|
26
|
+
|
|
27
|
+
// Status
|
|
28
|
+
private statusMessage: string = ""
|
|
29
|
+
private statusColor: string = "#888888"
|
|
30
|
+
private isSyncing: boolean = false
|
|
31
|
+
|
|
32
|
+
constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
|
|
33
|
+
super(renderer, {
|
|
34
|
+
id: "recyclarr-setup",
|
|
35
|
+
width: "100%",
|
|
36
|
+
height: "100%",
|
|
37
|
+
flexDirection: "column",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
this.cliRenderer = renderer
|
|
41
|
+
this.config = config
|
|
42
|
+
this.onBack = onBack
|
|
43
|
+
|
|
44
|
+
this.renderContent()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private renderContent(): void {
|
|
48
|
+
// Clear previous
|
|
49
|
+
if (this.keyHandler) {
|
|
50
|
+
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
51
|
+
this.keyHandler = null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const children = this.getChildren()
|
|
55
|
+
for (const child of children) {
|
|
56
|
+
this.remove(child.id)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const radarrEnabled = this.config.apps.some((a) => a.id === "radarr" && a.enabled)
|
|
60
|
+
const sonarrEnabled = this.config.apps.some((a) => a.id === "sonarr" && a.enabled)
|
|
61
|
+
const recyclarrEnabled = this.config.apps.some((a) => a.id === "recyclarr" && a.enabled)
|
|
62
|
+
|
|
63
|
+
const { container: page, content } = createPageLayout(this.cliRenderer, {
|
|
64
|
+
title: "♻️ Recyclarr Setup",
|
|
65
|
+
stepInfo: "Configure TRaSH Guides profile sync",
|
|
66
|
+
footerHint: [
|
|
67
|
+
{ type: "key", key: "Enter", value: "Select" },
|
|
68
|
+
{ type: "key", key: "q", value: "Back" },
|
|
69
|
+
],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// Status message
|
|
73
|
+
if (this.statusMessage) {
|
|
74
|
+
content.add(
|
|
75
|
+
new TextRenderable(this.cliRenderer, {
|
|
76
|
+
id: "status",
|
|
77
|
+
content: this.statusMessage,
|
|
78
|
+
fg: this.statusColor,
|
|
79
|
+
marginBottom: 1,
|
|
80
|
+
})
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!recyclarrEnabled) {
|
|
85
|
+
content.add(
|
|
86
|
+
new TextRenderable(this.cliRenderer, {
|
|
87
|
+
content: "⚠️ Recyclarr is not enabled. Enable it in App Manager first.",
|
|
88
|
+
fg: "#ff6666",
|
|
89
|
+
})
|
|
90
|
+
)
|
|
91
|
+
this.setupBackHandler(content)
|
|
92
|
+
this.add(page)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
switch (this.mode) {
|
|
97
|
+
case "main":
|
|
98
|
+
this.renderMainMenu(content, radarrEnabled, sonarrEnabled)
|
|
99
|
+
break
|
|
100
|
+
case "radarr":
|
|
101
|
+
this.renderRadarrProfiles(content)
|
|
102
|
+
break
|
|
103
|
+
case "sonarr":
|
|
104
|
+
this.renderSonarrProfiles(content)
|
|
105
|
+
break
|
|
106
|
+
case "sync":
|
|
107
|
+
this.renderSyncView(content)
|
|
108
|
+
break
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.add(page)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private renderMainMenu(content: BoxRenderable, radarrEnabled: boolean, sonarrEnabled: boolean): void {
|
|
115
|
+
content.add(
|
|
116
|
+
new TextRenderable(this.cliRenderer, {
|
|
117
|
+
content: "Recyclarr syncs TRaSH Guides custom formats and quality profiles to your *arr apps.",
|
|
118
|
+
fg: "#888888",
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
122
|
+
|
|
123
|
+
// Current config status
|
|
124
|
+
const configBox = new BoxRenderable(this.cliRenderer, {
|
|
125
|
+
width: "100%",
|
|
126
|
+
flexDirection: "column",
|
|
127
|
+
marginBottom: 1,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
if (radarrEnabled) {
|
|
131
|
+
const preset = RADARR_PRESETS.find((p) => p.id === this.radarrPreset)
|
|
132
|
+
configBox.add(
|
|
133
|
+
new TextRenderable(this.cliRenderer, {
|
|
134
|
+
content: `🎬 Radarr: ${preset?.name || "Default"}`,
|
|
135
|
+
fg: "#50fa7b",
|
|
136
|
+
})
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (sonarrEnabled) {
|
|
141
|
+
const preset = SONARR_PRESETS.find((p) => p.id === this.sonarrPreset)
|
|
142
|
+
configBox.add(
|
|
143
|
+
new TextRenderable(this.cliRenderer, {
|
|
144
|
+
content: `📺 Sonarr: ${preset?.name || "Default"}`,
|
|
145
|
+
fg: "#50fa7b",
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
content.add(configBox)
|
|
151
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
152
|
+
|
|
153
|
+
// Menu options
|
|
154
|
+
const options: Array<{ name: string; description: string }> = []
|
|
155
|
+
|
|
156
|
+
if (radarrEnabled) {
|
|
157
|
+
options.push({ name: "🎬 Configure Radarr Profile", description: "Select TRaSH Guide profile for movies" })
|
|
158
|
+
}
|
|
159
|
+
if (sonarrEnabled) {
|
|
160
|
+
options.push({ name: "📺 Configure Sonarr Profile", description: "Select TRaSH Guide profile for TV shows" })
|
|
161
|
+
}
|
|
162
|
+
options.push({ name: "🔄 Run Sync Now", description: "Manually trigger Recyclarr sync" })
|
|
163
|
+
options.push({ name: "💾 Save & Generate Config", description: "Save recyclarr.yml configuration" })
|
|
164
|
+
options.push({ name: "◀ Back", description: "Return to main menu" })
|
|
165
|
+
|
|
166
|
+
const menu = new SelectRenderable(this.cliRenderer, {
|
|
167
|
+
id: "recyclarr-main-menu",
|
|
168
|
+
width: "100%",
|
|
169
|
+
height: options.length * 2 + 1,
|
|
170
|
+
options,
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, async (index) => {
|
|
174
|
+
let currentIdx = 0
|
|
175
|
+
|
|
176
|
+
if (radarrEnabled) {
|
|
177
|
+
if (index === currentIdx) {
|
|
178
|
+
this.mode = "radarr"
|
|
179
|
+
this.renderContent()
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
currentIdx++
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (sonarrEnabled) {
|
|
186
|
+
if (index === currentIdx) {
|
|
187
|
+
this.mode = "sonarr"
|
|
188
|
+
this.renderContent()
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
currentIdx++
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (index === currentIdx) {
|
|
195
|
+
// Run Sync
|
|
196
|
+
await this.runSync()
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
currentIdx++
|
|
200
|
+
|
|
201
|
+
if (index === currentIdx) {
|
|
202
|
+
// Save Config
|
|
203
|
+
await this.saveConfig()
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
currentIdx++
|
|
207
|
+
|
|
208
|
+
// Back
|
|
209
|
+
this.cleanup()
|
|
210
|
+
this.onBack()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
content.add(menu)
|
|
214
|
+
menu.focus()
|
|
215
|
+
|
|
216
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
217
|
+
if (key.name === "q" || key.name === "escape") {
|
|
218
|
+
this.cleanup()
|
|
219
|
+
this.onBack()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private renderRadarrProfiles(content: BoxRenderable): void {
|
|
226
|
+
content.add(
|
|
227
|
+
new TextRenderable(this.cliRenderer, {
|
|
228
|
+
content: "Select a TRaSH Guide profile for Radarr (Movies):",
|
|
229
|
+
fg: "#888888",
|
|
230
|
+
})
|
|
231
|
+
)
|
|
232
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
233
|
+
|
|
234
|
+
const options = RADARR_PRESETS.map((p) => ({
|
|
235
|
+
name: `${this.radarrPreset === p.id ? "● " : "○ "}${p.name}`,
|
|
236
|
+
description: p.description,
|
|
237
|
+
}))
|
|
238
|
+
options.push({ name: "◀ Back", description: "Return to main menu" })
|
|
239
|
+
|
|
240
|
+
const menu = new SelectRenderable(this.cliRenderer, {
|
|
241
|
+
id: "radarr-profiles-menu",
|
|
242
|
+
width: "100%",
|
|
243
|
+
height: options.length * 2 + 1,
|
|
244
|
+
options,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
|
|
248
|
+
if (index < RADARR_PRESETS.length) {
|
|
249
|
+
this.radarrPreset = RADARR_PRESETS[index].id
|
|
250
|
+
this.setStatus(`✓ Radarr profile set to: ${RADARR_PRESETS[index].name}`, "#50fa7b")
|
|
251
|
+
}
|
|
252
|
+
this.mode = "main"
|
|
253
|
+
this.renderContent()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
content.add(menu)
|
|
257
|
+
menu.focus()
|
|
258
|
+
|
|
259
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
260
|
+
if (key.name === "q" || key.name === "escape") {
|
|
261
|
+
this.mode = "main"
|
|
262
|
+
this.renderContent()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private renderSonarrProfiles(content: BoxRenderable): void {
|
|
269
|
+
content.add(
|
|
270
|
+
new TextRenderable(this.cliRenderer, {
|
|
271
|
+
content: "Select a TRaSH Guide profile for Sonarr (TV Shows):",
|
|
272
|
+
fg: "#888888",
|
|
273
|
+
})
|
|
274
|
+
)
|
|
275
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
276
|
+
|
|
277
|
+
const options = SONARR_PRESETS.map((p) => ({
|
|
278
|
+
name: `${this.sonarrPreset === p.id ? "● " : "○ "}${p.name}`,
|
|
279
|
+
description: p.description,
|
|
280
|
+
}))
|
|
281
|
+
options.push({ name: "◀ Back", description: "Return to main menu" })
|
|
282
|
+
|
|
283
|
+
const menu = new SelectRenderable(this.cliRenderer, {
|
|
284
|
+
id: "sonarr-profiles-menu",
|
|
285
|
+
width: "100%",
|
|
286
|
+
height: options.length * 2 + 1,
|
|
287
|
+
options,
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
|
|
291
|
+
if (index < SONARR_PRESETS.length) {
|
|
292
|
+
this.sonarrPreset = SONARR_PRESETS[index].id
|
|
293
|
+
this.setStatus(`✓ Sonarr profile set to: ${SONARR_PRESETS[index].name}`, "#50fa7b")
|
|
294
|
+
}
|
|
295
|
+
this.mode = "main"
|
|
296
|
+
this.renderContent()
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
content.add(menu)
|
|
300
|
+
menu.focus()
|
|
301
|
+
|
|
302
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
303
|
+
if (key.name === "q" || key.name === "escape") {
|
|
304
|
+
this.mode = "main"
|
|
305
|
+
this.renderContent()
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private renderSyncView(content: BoxRenderable): void {
|
|
312
|
+
content.add(
|
|
313
|
+
new TextRenderable(this.cliRenderer, {
|
|
314
|
+
content: this.isSyncing ? "⏳ Running Recyclarr sync..." : "Sync complete!",
|
|
315
|
+
fg: this.isSyncing ? "#f1fa8c" : "#50fa7b",
|
|
316
|
+
})
|
|
317
|
+
)
|
|
318
|
+
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
319
|
+
|
|
320
|
+
if (!this.isSyncing) {
|
|
321
|
+
const menu = new SelectRenderable(this.cliRenderer, {
|
|
322
|
+
id: "sync-done-menu",
|
|
323
|
+
width: "100%",
|
|
324
|
+
height: 2,
|
|
325
|
+
options: [{ name: "◀ Back", description: "Return to main menu" }],
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
329
|
+
this.mode = "main"
|
|
330
|
+
this.renderContent()
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
content.add(menu)
|
|
334
|
+
menu.focus()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
338
|
+
if (!this.isSyncing && (key.name === "q" || key.name === "escape")) {
|
|
339
|
+
this.mode = "main"
|
|
340
|
+
this.renderContent()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async runSync(): Promise<void> {
|
|
347
|
+
this.isSyncing = true
|
|
348
|
+
this.mode = "sync"
|
|
349
|
+
this.renderContent()
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
// First save the config
|
|
353
|
+
await this.saveConfig()
|
|
354
|
+
|
|
355
|
+
// Run recyclarr sync
|
|
356
|
+
const result = await composeRun("recyclarr", "sync")
|
|
357
|
+
|
|
358
|
+
if (result.success) {
|
|
359
|
+
this.setStatus("✓ Recyclarr sync completed successfully!", "#50fa7b")
|
|
360
|
+
} else {
|
|
361
|
+
this.setStatus(`⚠ Sync completed with warnings: ${result.output.substring(0, 100)}`, "#f1fa8c")
|
|
362
|
+
}
|
|
363
|
+
} catch (err) {
|
|
364
|
+
this.setStatus(`✗ Sync failed: ${(err as Error).message}`, "#ff5555")
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
this.isSyncing = false
|
|
368
|
+
this.mode = "main"
|
|
369
|
+
this.renderContent()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private async saveConfig(): Promise<void> {
|
|
373
|
+
try {
|
|
374
|
+
await saveRecyclarrConfig(this.config)
|
|
375
|
+
this.setStatus("✓ Recyclarr config saved!", "#50fa7b")
|
|
376
|
+
} catch (err) {
|
|
377
|
+
this.setStatus(`✗ Failed to save config: ${(err as Error).message}`, "#ff5555")
|
|
378
|
+
}
|
|
379
|
+
this.renderContent()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private setStatus(message: string, color: string): void {
|
|
383
|
+
this.statusMessage = message
|
|
384
|
+
this.statusColor = color
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private setupBackHandler(content: BoxRenderable): void {
|
|
388
|
+
const menu = new SelectRenderable(this.cliRenderer, {
|
|
389
|
+
id: "recyclarr-back-menu",
|
|
390
|
+
width: "100%",
|
|
391
|
+
height: 2,
|
|
392
|
+
options: [{ name: "◀ Back", description: "Return to main menu" }],
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
menu.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
396
|
+
this.cleanup()
|
|
397
|
+
this.onBack()
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
content.add(menu)
|
|
401
|
+
menu.focus()
|
|
402
|
+
|
|
403
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
404
|
+
if (key.name === "q" || key.name === "escape") {
|
|
405
|
+
this.cleanup()
|
|
406
|
+
this.onBack()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
this.cliRenderer.keyInput.on("keypress", this.keyHandler)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private cleanup(): void {
|
|
413
|
+
if (this.keyHandler) {
|
|
414
|
+
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
415
|
+
this.keyHandler = null
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -41,6 +41,7 @@ export class SettingsScreen extends BoxRenderable {
|
|
|
41
41
|
private pgid: string
|
|
42
42
|
private timezone: string
|
|
43
43
|
private umask: string
|
|
44
|
+
private logMount: boolean
|
|
44
45
|
|
|
45
46
|
constructor(renderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
|
|
46
47
|
super(renderer, {
|
|
@@ -66,6 +67,7 @@ export class SettingsScreen extends BoxRenderable {
|
|
|
66
67
|
this.pgid = config.gid.toString()
|
|
67
68
|
this.timezone = config.timezone
|
|
68
69
|
this.umask = config.umask
|
|
70
|
+
this.logMount = config.logMount ?? false
|
|
69
71
|
|
|
70
72
|
this.renderContent()
|
|
71
73
|
}
|
|
@@ -427,6 +429,37 @@ export class SettingsScreen extends BoxRenderable {
|
|
|
427
429
|
tzInput.on(InputRenderableEvents.CHANGE, (v) => (this.timezone = v))
|
|
428
430
|
umaskInput.on(InputRenderableEvents.CHANGE, (v) => (this.umask = v))
|
|
429
431
|
|
|
432
|
+
// Log mount toggle
|
|
433
|
+
const logMountRow = new BoxRenderable(this.cliRenderer, {
|
|
434
|
+
width: "100%",
|
|
435
|
+
height: 1,
|
|
436
|
+
flexDirection: "row",
|
|
437
|
+
marginBottom: 1,
|
|
438
|
+
})
|
|
439
|
+
logMountRow.add(
|
|
440
|
+
new TextRenderable(this.cliRenderer, {
|
|
441
|
+
content: "Bind Logs:".padEnd(16),
|
|
442
|
+
fg: "#aaaaaa",
|
|
443
|
+
})
|
|
444
|
+
)
|
|
445
|
+
const logMountToggle = new SelectRenderable(this.cliRenderer, {
|
|
446
|
+
id: "settings-sys-logmount",
|
|
447
|
+
width: 30,
|
|
448
|
+
height: 1,
|
|
449
|
+
options: [
|
|
450
|
+
{
|
|
451
|
+
name: this.logMount ? "✓ Enabled" : "○ Disabled",
|
|
452
|
+
description: "Bind-mount container logs to ${ROOT_DIR}/logs/",
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
})
|
|
456
|
+
logMountToggle.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
457
|
+
this.logMount = !this.logMount
|
|
458
|
+
this.renderContent()
|
|
459
|
+
})
|
|
460
|
+
logMountRow.add(logMountToggle)
|
|
461
|
+
content.add(logMountRow)
|
|
462
|
+
|
|
430
463
|
content.add(new TextRenderable(this.cliRenderer, { content: " " }))
|
|
431
464
|
|
|
432
465
|
const navMenu = new SelectRenderable(this.cliRenderer, {
|
|
@@ -450,7 +483,7 @@ export class SettingsScreen extends BoxRenderable {
|
|
|
450
483
|
|
|
451
484
|
content.add(navMenu)
|
|
452
485
|
|
|
453
|
-
const inputs = [rootInput, puidInput, pgidInput, tzInput, umaskInput, navMenu]
|
|
486
|
+
const inputs = [rootInput, puidInput, pgidInput, tzInput, umaskInput, logMountToggle, navMenu]
|
|
454
487
|
let focusIndex = 0
|
|
455
488
|
inputs[0].focus()
|
|
456
489
|
|
|
@@ -549,6 +582,7 @@ export class SettingsScreen extends BoxRenderable {
|
|
|
549
582
|
this.config.gid = parseInt(this.pgid, 10) || 1000
|
|
550
583
|
this.config.timezone = this.timezone
|
|
551
584
|
this.config.umask = this.umask
|
|
585
|
+
this.config.logMount = this.logMount
|
|
552
586
|
this.config.updatedAt = new Date().toISOString()
|
|
553
587
|
|
|
554
588
|
await saveConfig(this.config)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log File Management Utilities
|
|
3
|
+
* Handles saving and managing container logs to ~/.easiarr/logs/
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdir, writeFile, readdir, stat } from "node:fs/promises"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import { homedir } from "node:os"
|
|
9
|
+
import { debugLog } from "./debug"
|
|
10
|
+
|
|
11
|
+
const LOGS_DIR = join(homedir(), ".easiarr", "logs")
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the logs directory path for a specific app
|
|
15
|
+
*/
|
|
16
|
+
export function getLogPath(appId: string): string {
|
|
17
|
+
return join(LOGS_DIR, appId)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ensure the logs directory exists for an app
|
|
22
|
+
*/
|
|
23
|
+
async function ensureLogDir(appId: string): Promise<string> {
|
|
24
|
+
const logDir = getLogPath(appId)
|
|
25
|
+
await mkdir(logDir, { recursive: true })
|
|
26
|
+
return logDir
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate a timestamped log filename
|
|
31
|
+
*/
|
|
32
|
+
function generateLogFilename(): string {
|
|
33
|
+
const now = new Date()
|
|
34
|
+
const timestamp = now.toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19)
|
|
35
|
+
return `${timestamp}.log`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Save container logs to a file
|
|
40
|
+
* @returns The path to the saved log file
|
|
41
|
+
*/
|
|
42
|
+
export async function saveLog(appId: string, content: string): Promise<string> {
|
|
43
|
+
const logDir = await ensureLogDir(appId)
|
|
44
|
+
const filename = generateLogFilename()
|
|
45
|
+
const filepath = join(logDir, filename)
|
|
46
|
+
|
|
47
|
+
await writeFile(filepath, content, "utf-8")
|
|
48
|
+
debugLog("Logs", `Saved log for ${appId} to ${filepath}`)
|
|
49
|
+
|
|
50
|
+
return filepath
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* List saved logs for an app
|
|
55
|
+
* @returns Array of log file info sorted by date (newest first)
|
|
56
|
+
*/
|
|
57
|
+
export async function listSavedLogs(
|
|
58
|
+
appId: string
|
|
59
|
+
): Promise<Array<{ filename: string; path: string; date: Date; size: number }>> {
|
|
60
|
+
const logDir = getLogPath(appId)
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const files = await readdir(logDir)
|
|
64
|
+
const logFiles = files.filter((f) => f.endsWith(".log"))
|
|
65
|
+
|
|
66
|
+
const fileInfos = await Promise.all(
|
|
67
|
+
logFiles.map(async (filename) => {
|
|
68
|
+
const path = join(logDir, filename)
|
|
69
|
+
const stats = await stat(path)
|
|
70
|
+
return {
|
|
71
|
+
filename,
|
|
72
|
+
path,
|
|
73
|
+
date: stats.mtime,
|
|
74
|
+
size: stats.size,
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// Sort by date, newest first
|
|
80
|
+
return fileInfos.sort((a, b) => b.date.getTime() - a.date.getTime())
|
|
81
|
+
} catch {
|
|
82
|
+
// Directory doesn't exist yet
|
|
83
|
+
return []
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the base logs directory path
|
|
89
|
+
*/
|
|
90
|
+
export function getLogsBaseDir(): string {
|
|
91
|
+
return LOGS_DIR
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Format bytes to human-readable size
|
|
96
|
+
*/
|
|
97
|
+
export function formatBytes(bytes: number): string {
|
|
98
|
+
if (bytes < 1024) return `${bytes} B`
|
|
99
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
100
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Format relative time (e.g., "2 hours ago")
|
|
105
|
+
*/
|
|
106
|
+
export function formatRelativeTime(date: Date): string {
|
|
107
|
+
const now = new Date()
|
|
108
|
+
const diffMs = now.getTime() - date.getTime()
|
|
109
|
+
const diffSecs = Math.floor(diffMs / 1000)
|
|
110
|
+
const diffMins = Math.floor(diffSecs / 60)
|
|
111
|
+
const diffHours = Math.floor(diffMins / 60)
|
|
112
|
+
const diffDays = Math.floor(diffHours / 24)
|
|
113
|
+
|
|
114
|
+
if (diffDays > 0) return `${diffDays}d ago`
|
|
115
|
+
if (diffHours > 0) return `${diffHours}h ago`
|
|
116
|
+
if (diffMins > 0) return `${diffMins}m ago`
|
|
117
|
+
return "just now"
|
|
118
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unraid Detection and Compatibility Utilities
|
|
3
|
+
* Provides detection of Unraid environment and path adjustments
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from "node:fs"
|
|
7
|
+
import { homedir } from "node:os"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import { debugLog } from "./debug"
|
|
10
|
+
|
|
11
|
+
// Unraid-specific paths
|
|
12
|
+
const UNRAID_IDENTIFIERS = [
|
|
13
|
+
"/boot/config/plugins", // Unraid plugin directory
|
|
14
|
+
"/etc/unraid-version", // Unraid version file
|
|
15
|
+
"/var/local/emhttp", // Unraid emhttp directory
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const UNRAID_APPDATA_PATH = "/mnt/user/appdata"
|
|
19
|
+
const COMPOSE_MANAGER_PLUGIN_PATH = "/boot/config/plugins/compose.manager"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect if running on Unraid OS
|
|
23
|
+
* Checks for Unraid-specific filesystem paths
|
|
24
|
+
*/
|
|
25
|
+
export function isUnraid(): boolean {
|
|
26
|
+
for (const path of UNRAID_IDENTIFIERS) {
|
|
27
|
+
if (existsSync(path)) {
|
|
28
|
+
debugLog("Unraid", `Detected Unraid OS (found ${path})`)
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the default appdata path for Unraid
|
|
37
|
+
* Returns /mnt/user/appdata/easiarr on Unraid, ~/.easiarr otherwise
|
|
38
|
+
*/
|
|
39
|
+
export function getUnraidAppDataPath(): string {
|
|
40
|
+
if (isUnraid()) {
|
|
41
|
+
return join(UNRAID_APPDATA_PATH, "easiarr")
|
|
42
|
+
}
|
|
43
|
+
return join(homedir(), ".easiarr")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if Docker Compose Manager plugin is installed
|
|
48
|
+
* This plugin allows managing compose stacks via Unraid's web UI
|
|
49
|
+
*/
|
|
50
|
+
export function hasComposeManager(): boolean {
|
|
51
|
+
return existsSync(COMPOSE_MANAGER_PLUGIN_PATH)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the recommended Compose Manager project directory
|
|
56
|
+
* Compose Manager expects projects in /boot/config/plugins/compose.manager/projects/
|
|
57
|
+
*/
|
|
58
|
+
export function getComposeManagerProjectPath(projectName: string = "easiarr"): string {
|
|
59
|
+
return join(COMPOSE_MANAGER_PLUGIN_PATH, "projects", projectName)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get platform-appropriate default root directory
|
|
64
|
+
* Adjusts paths for Unraid vs standard Linux/Mac
|
|
65
|
+
*/
|
|
66
|
+
export function getDefaultRootDir(): string {
|
|
67
|
+
if (isUnraid()) {
|
|
68
|
+
// On Unraid, use /mnt/user/data for media and /mnt/user/appdata for configs
|
|
69
|
+
return "/mnt/user/data"
|
|
70
|
+
}
|
|
71
|
+
// Standard path
|
|
72
|
+
return join(homedir(), "easiarr")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get platform-appropriate config directory
|
|
77
|
+
*/
|
|
78
|
+
export function getConfigDir(): string {
|
|
79
|
+
if (isUnraid()) {
|
|
80
|
+
return UNRAID_APPDATA_PATH
|
|
81
|
+
}
|
|
82
|
+
return join(homedir(), ".easiarr")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get Unraid-specific information for display
|
|
87
|
+
*/
|
|
88
|
+
export function getUnraidInfo(): {
|
|
89
|
+
isUnraid: boolean
|
|
90
|
+
hasComposeManager: boolean
|
|
91
|
+
appDataPath: string
|
|
92
|
+
composeManagerPath: string | null
|
|
93
|
+
} {
|
|
94
|
+
const onUnraid = isUnraid()
|
|
95
|
+
return {
|
|
96
|
+
isUnraid: onUnraid,
|
|
97
|
+
hasComposeManager: onUnraid ? hasComposeManager() : false,
|
|
98
|
+
appDataPath: onUnraid ? UNRAID_APPDATA_PATH : join(homedir(), ".easiarr"),
|
|
99
|
+
composeManagerPath: onUnraid && hasComposeManager() ? getComposeManagerProjectPath() : null,
|
|
100
|
+
}
|
|
101
|
+
}
|