@muhammedaksam/easiarr 1.1.7 → 1.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.
@@ -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
+ }
@@ -6,7 +6,7 @@
6
6
  * Each migration exports: name, up()
7
7
  */
8
8
 
9
- import { existsSync, readFileSync, writeFileSync } from "node:fs"
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
10
10
  import { join } from "node:path"
11
11
  import { homedir } from "node:os"
12
12
  import { debugLog } from "./debug"
@@ -26,6 +26,15 @@ interface Migration {
26
26
  down: () => boolean
27
27
  }
28
28
 
29
+ /**
30
+ * Ensure the easiarr directory exists
31
+ */
32
+ function ensureEasiarrDir(): void {
33
+ if (!existsSync(EASIARR_DIR)) {
34
+ mkdirSync(EASIARR_DIR, { recursive: true })
35
+ }
36
+ }
37
+
29
38
  /**
30
39
  * Get the current migration state
31
40
  */
@@ -46,6 +55,7 @@ function getMigrationState(): MigrationState {
46
55
  * Save the migration state
47
56
  */
48
57
  function saveMigrationState(state: MigrationState): void {
58
+ ensureEasiarrDir()
49
59
  state.lastRun = new Date().toISOString()
50
60
  writeFileSync(MIGRATIONS_FILE, JSON.stringify(state, null, 2), "utf-8")
51
61
  }