@muhammedaksam/easiarr 0.1.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,129 @@
1
+ /**
2
+ * Docker Client
3
+ * Wrapper for docker compose commands using Bun.$
4
+ */
5
+
6
+ import { $ } from "bun"
7
+ import { getComposePath } from "../config/manager"
8
+
9
+ export interface ContainerStatus {
10
+ name: string
11
+ status: "running" | "stopped" | "not_found"
12
+ ports?: string
13
+ }
14
+
15
+ export async function composeUp(): Promise<{
16
+ success: boolean
17
+ output: string
18
+ }> {
19
+ try {
20
+ const composePath = getComposePath()
21
+ const result = await $`docker compose -f ${composePath} up -d`.text()
22
+ return { success: true, output: result }
23
+ } catch (error) {
24
+ return { success: false, output: String(error) }
25
+ }
26
+ }
27
+
28
+ export async function composeDown(): Promise<{
29
+ success: boolean
30
+ output: string
31
+ }> {
32
+ try {
33
+ const composePath = getComposePath()
34
+ const result = await $`docker compose -f ${composePath} down`.text()
35
+ return { success: true, output: result }
36
+ } catch (error) {
37
+ return { success: false, output: String(error) }
38
+ }
39
+ }
40
+
41
+ export async function composeRestart(service?: string): Promise<{ success: boolean; output: string }> {
42
+ try {
43
+ const composePath = getComposePath()
44
+ const result = service
45
+ ? await $`docker compose -f ${composePath} restart ${service}`.text()
46
+ : await $`docker compose -f ${composePath} restart`.text()
47
+ return { success: true, output: result }
48
+ } catch (error) {
49
+ return { success: false, output: String(error) }
50
+ }
51
+ }
52
+
53
+ export async function composeStop(service?: string): Promise<{ success: boolean; output: string }> {
54
+ try {
55
+ const composePath = getComposePath()
56
+ const result = service
57
+ ? await $`docker compose -f ${composePath} stop ${service}`.text()
58
+ : await $`docker compose -f ${composePath} stop`.text()
59
+ return { success: true, output: result }
60
+ } catch (error) {
61
+ return { success: false, output: String(error) }
62
+ }
63
+ }
64
+
65
+ export async function composeStart(service?: string): Promise<{ success: boolean; output: string }> {
66
+ try {
67
+ const composePath = getComposePath()
68
+ const result = service
69
+ ? await $`docker compose -f ${composePath} start ${service}`.text()
70
+ : await $`docker compose -f ${composePath} start`.text()
71
+ return { success: true, output: result }
72
+ } catch (error) {
73
+ return { success: false, output: String(error) }
74
+ }
75
+ }
76
+
77
+ export async function getContainerStatuses(): Promise<ContainerStatus[]> {
78
+ try {
79
+ const composePath = getComposePath()
80
+ const result = await $`docker compose -f ${composePath} ps --format json`.text()
81
+
82
+ if (!result.trim()) {
83
+ return []
84
+ }
85
+
86
+ // Parse JSON lines output
87
+ const lines = result.trim().split("\n")
88
+ const statuses: ContainerStatus[] = []
89
+
90
+ for (const line of lines) {
91
+ try {
92
+ const container = JSON.parse(line)
93
+ statuses.push({
94
+ name: container.Name || container.Service,
95
+ status: container.State === "running" ? "running" : "stopped",
96
+ ports: container.Ports,
97
+ })
98
+ } catch {
99
+ // Skip malformed lines
100
+ }
101
+ }
102
+
103
+ return statuses
104
+ } catch {
105
+ return []
106
+ }
107
+ }
108
+
109
+ export async function pullImages(): Promise<{
110
+ success: boolean
111
+ output: string
112
+ }> {
113
+ try {
114
+ const composePath = getComposePath()
115
+ const result = await $`docker compose -f ${composePath} pull`.text()
116
+ return { success: true, output: result }
117
+ } catch (error) {
118
+ return { success: false, output: String(error) }
119
+ }
120
+ }
121
+
122
+ export async function isDockerAvailable(): Promise<boolean> {
123
+ try {
124
+ await $`docker --version`.quiet()
125
+ return true
126
+ } catch {
127
+ return false
128
+ }
129
+ }
@@ -0,0 +1 @@
1
+ export * from "./client"
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Easiarr Entry Point
4
+ * TUI tool for generating docker-compose files for the *arr ecosystem
5
+ */
6
+
7
+ import { createCliRenderer } from "@opentui/core"
8
+ import { App } from "./ui/App"
9
+
10
+ async function main() {
11
+ const renderer = await createCliRenderer({
12
+ consoleOptions: {
13
+ startInDebugMode: false,
14
+ },
15
+ })
16
+
17
+ const app = new App(renderer)
18
+ await app.start()
19
+ }
20
+
21
+ main().catch((error) => {
22
+ console.error("Fatal error:", error)
23
+ process.exit(1)
24
+ })
@@ -0,0 +1,86 @@
1
+ import { mkdir } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+ import type { EasiarrConfig, AppId } from "../config/schema"
4
+
5
+ const BASE_DIRS = ["torrents", "usenet", "media"]
6
+
7
+ // Map apps to their content type folders
8
+ const CONTENT_TYPE_MAP: Partial<Record<AppId, string>> = {
9
+ radarr: "movies",
10
+ sonarr: "tv",
11
+ lidarr: "music",
12
+ readarr: "books",
13
+ mylar3: "comics",
14
+ whisparr: "adult",
15
+ }
16
+
17
+ export async function ensureDirectoryStructure(config: EasiarrConfig): Promise<void> {
18
+ try {
19
+ const dataRoot = join(config.rootDir, "data")
20
+
21
+ // Create base data directory
22
+ await mkdir(dataRoot, { recursive: true })
23
+
24
+ // 1. Create Base Directories (torrents, usenet, media)
25
+ for (const dir of BASE_DIRS) {
26
+ await mkdir(join(dataRoot, dir), { recursive: true })
27
+ }
28
+
29
+ // 2. Create Content Subdirectories based on enabled apps
30
+ const enabledApps = new Set(config.apps.filter((a) => a.enabled).map((a) => a.id))
31
+
32
+ for (const [appId, contentType] of Object.entries(CONTENT_TYPE_MAP)) {
33
+ if (enabledApps.has(appId as AppId)) {
34
+ // Create this content type in ALL base dirs to follow TRaSH standard
35
+ // (e.g. data/torrents/movies, data/usenet/movies, data/media/movies)
36
+ for (const base of BASE_DIRS) {
37
+ await mkdir(join(dataRoot, base, contentType), { recursive: true })
38
+ }
39
+ }
40
+ }
41
+
42
+ // 3. Special cases & MediaStack Extras
43
+
44
+ // Always create 'photos' in media (Personal photos)
45
+ await mkdir(join(dataRoot, "media", "photos"), { recursive: true })
46
+
47
+ // Always create 'console' and 'software' in torrents/usenet (Manual DL categories)
48
+ for (const base of ["torrents", "usenet"]) {
49
+ await mkdir(join(dataRoot, base, "console"), { recursive: true })
50
+ await mkdir(join(dataRoot, base, "software"), { recursive: true })
51
+ // Create 'watch' folder for manual .torrent/.nzb drops
52
+ await mkdir(join(dataRoot, base, "watch"), { recursive: true })
53
+
54
+ // 'complete' and 'incomplete' default folders
55
+ await mkdir(join(dataRoot, base, "complete"), { recursive: true })
56
+ await mkdir(join(dataRoot, base, "incomplete"), { recursive: true })
57
+ }
58
+
59
+ if (enabledApps.has("prowlarr")) {
60
+ // Prowlarr uncategorized downloads
61
+ await mkdir(join(dataRoot, "torrents", "prowlarr"), { recursive: true })
62
+ await mkdir(join(dataRoot, "usenet", "prowlarr"), { recursive: true })
63
+ }
64
+
65
+ if (enabledApps.has("filebot")) {
66
+ await mkdir(join(dataRoot, "filebot", "input"), { recursive: true })
67
+ await mkdir(join(dataRoot, "filebot", "output"), { recursive: true })
68
+ }
69
+
70
+ if (enabledApps.has("audiobookshelf")) {
71
+ // Audiobookshelf usually resides in media/audiobooks and media/podcasts
72
+ await mkdir(join(dataRoot, "media", "audiobooks"), { recursive: true })
73
+ await mkdir(join(dataRoot, "media", "podcasts"), { recursive: true })
74
+ }
75
+ } catch (error: unknown) {
76
+ const err = error as { code?: string; message: string }
77
+ if (err.code === "EACCES") {
78
+ console.error(`\n⚠ Warning: Permission denied when creating directories at ${config.rootDir}`)
79
+ console.error(" Please create the folders manually or check directory permissions.")
80
+ console.error(` Error: ${err.message}\n`)
81
+ // Do not throw, allow setup to continue
82
+ } else {
83
+ throw error
84
+ }
85
+ }
86
+ }
package/src/ui/App.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Main Application Controller
3
+ * Manages navigation between screens
4
+ */
5
+
6
+ import type { KeyEvent, CliRenderer } from "@opentui/core"
7
+ import { BoxRenderable } from "@opentui/core"
8
+ import { loadConfig, configExists, saveConfig } from "../config"
9
+ import type { EasiarrConfig } from "../config/schema"
10
+ import { MainMenu } from "./screens/MainMenu"
11
+ import { QuickSetup } from "./screens/QuickSetup"
12
+ import { AppManager } from "./screens/AppManager"
13
+ import { ContainerControl } from "./screens/ContainerControl"
14
+ import { AdvancedSettings } from "./screens/AdvancedSettings"
15
+
16
+ export type Screen = "main" | "quickSetup" | "appManager" | "containerControl" | "advancedSettings"
17
+
18
+ export class App {
19
+ private renderer: CliRenderer
20
+ private config: EasiarrConfig | null = null
21
+ private currentScreen: Screen = "main"
22
+ private screenContainer: BoxRenderable
23
+
24
+ constructor(renderer: CliRenderer) {
25
+ this.renderer = renderer
26
+ this.screenContainer = new BoxRenderable(renderer, {
27
+ id: "screen-container",
28
+ width: "100%",
29
+ height: "100%",
30
+ })
31
+ renderer.root.add(this.screenContainer)
32
+ }
33
+
34
+ async start(): Promise<void> {
35
+ // Load existing config or show quick setup
36
+ if (await configExists()) {
37
+ this.config = await loadConfig()
38
+ }
39
+
40
+ if (!this.config) {
41
+ this.navigateTo("quickSetup")
42
+ } else {
43
+ this.navigateTo("main")
44
+ }
45
+
46
+ // Handle exit
47
+ this.renderer.keyInput.on("keypress", (key: KeyEvent) => {
48
+ if (key.ctrl && key.name === "c") {
49
+ process.exit(0)
50
+ }
51
+ })
52
+ }
53
+
54
+ navigateTo(screen: Screen): void {
55
+ this.currentScreen = screen
56
+
57
+ // Clear all children from container
58
+ const children = this.screenContainer.getChildren()
59
+ for (const child of children) {
60
+ this.screenContainer.remove(child.id)
61
+ }
62
+
63
+ switch (screen) {
64
+ case "main":
65
+ new MainMenu(this.renderer, this.screenContainer, this, this.config!)
66
+ break
67
+ case "quickSetup":
68
+ new QuickSetup(this.renderer, this.screenContainer, this)
69
+ break
70
+ case "appManager":
71
+ new AppManager(this.renderer, this.screenContainer, this, this.config!)
72
+ break
73
+ case "containerControl":
74
+ new ContainerControl(this.renderer, this.screenContainer, this, this.config!)
75
+ break
76
+ case "advancedSettings":
77
+ new AdvancedSettings(this.renderer, this.screenContainer, this, this.config!)
78
+ break
79
+ }
80
+ }
81
+
82
+ async saveAndReload(config: EasiarrConfig): Promise<void> {
83
+ await saveConfig(config)
84
+ this.config = config
85
+ this.navigateTo("main")
86
+ }
87
+
88
+ setConfig(config: EasiarrConfig): void {
89
+ this.config = config
90
+ }
91
+
92
+ getConfig(): EasiarrConfig | null {
93
+ return this.config
94
+ }
95
+ }
@@ -0,0 +1,256 @@
1
+ import {
2
+ BoxRenderable,
3
+ BoxOptions,
4
+ CliRenderer,
5
+ RenderContext,
6
+ TabSelectRenderable,
7
+ SelectRenderable,
8
+ TextRenderable,
9
+ SelectRenderableEvents,
10
+ KeyEvent,
11
+ } from "@opentui/core"
12
+ import { AppId } from "../../config/schema"
13
+ import { CATEGORY_ORDER } from "../../apps/categories"
14
+ import { getAppsByCategory } from "../../apps"
15
+
16
+ export interface ApplicationSelectorOptions extends BoxOptions {
17
+ selectedApps: Set<AppId>
18
+ onToggle?: (appId: AppId, enabled: boolean) => void
19
+ }
20
+
21
+ export class ApplicationSelector extends BoxRenderable {
22
+ private selectedApps: Set<AppId>
23
+ private onToggle?: (appId: AppId, enabled: boolean) => void
24
+ private currentCategoryIndex: number = 0
25
+ private tabs: TabSelectRenderable
26
+ private appList: SelectRenderable
27
+ private warningBox: BoxRenderable
28
+ private _renderer: CliRenderer | RenderContext
29
+
30
+ // Track internal focus state
31
+ private activeComponent: "tabs" | "list" = "tabs"
32
+
33
+ constructor(renderer: CliRenderer | RenderContext, options: ApplicationSelectorOptions) {
34
+ super(renderer, {
35
+ ...options,
36
+ flexDirection: "column",
37
+ })
38
+
39
+ this._renderer = renderer
40
+ this.selectedApps = options.selectedApps
41
+ this.onToggle = options.onToggle
42
+
43
+ // 1. Conflict Warnings Area (Dynamic)
44
+ this.warningBox = new BoxRenderable(renderer, {
45
+ width: "100%",
46
+ flexDirection: "column",
47
+ marginBottom: 1,
48
+ })
49
+ this.add(this.warningBox)
50
+ this.updateWarnings()
51
+
52
+ // 2. Category Tabs
53
+ const tabOptions = this.getTabOptions()
54
+ this.tabs = new TabSelectRenderable(renderer, {
55
+ width: "100%" /** Explicit width needed for tabs usually */,
56
+ options: tabOptions,
57
+ tabWidth: 12,
58
+ showUnderline: false,
59
+ showDescription: false,
60
+ selectedBackgroundColor: "#4a9eff",
61
+ textColor: "#555555",
62
+ })
63
+
64
+ // Event listener replaced by manual key handling for instant switching
65
+ /*
66
+ this.tabs.on(TabSelectRenderableEvents.ITEM_SELECTED, (index) => {
67
+ if (this.currentCategoryIndex !== index) {
68
+ this.currentCategoryIndex = index
69
+ this.updateAppList()
70
+ this.activeComponent = "tabs"
71
+ this.tabs.focus()
72
+ }
73
+ })
74
+ */
75
+
76
+ this.add(this.tabs)
77
+
78
+ // Spacer
79
+ this.add(new TextRenderable(renderer, { content: " " }))
80
+
81
+ // 3. App List
82
+ this.appList = new SelectRenderable(renderer, {
83
+ width: "100%",
84
+ flexGrow: 1,
85
+ backgroundColor: "#151525",
86
+ focusedBackgroundColor: "#252545",
87
+ selectedBackgroundColor: "#3a4a6e",
88
+ showScrollIndicator: true,
89
+ options: [], // populated via updateAppList
90
+ })
91
+
92
+ this.appList.on(SelectRenderableEvents.ITEM_SELECTED, (index) => {
93
+ const category = CATEGORY_ORDER[this.currentCategoryIndex]
94
+ const apps = getAppsByCategory()[category.id] || []
95
+ const app = apps[index]
96
+
97
+ if (app) {
98
+ const isEnabled = !this.selectedApps.has(app.id)
99
+ if (isEnabled) {
100
+ this.selectedApps.add(app.id)
101
+ } else {
102
+ this.selectedApps.delete(app.id)
103
+ }
104
+
105
+ if (this.onToggle) {
106
+ this.onToggle(app.id, isEnabled)
107
+ }
108
+
109
+ // Refresh list to show checkmark
110
+ this.updateAppList()
111
+
112
+ // Refresh tabs to show counts
113
+ this.tabs.options = this.getTabOptions()
114
+ // Refresh warnings
115
+ this.updateWarnings()
116
+ }
117
+ })
118
+
119
+ this.add(this.appList)
120
+
121
+ // Initial population
122
+ this.updateAppList()
123
+ }
124
+
125
+ private getTabOptions() {
126
+ return CATEGORY_ORDER.map((cat) => {
127
+ const categoryApps = getAppsByCategory()[cat.id] || []
128
+ const selectedCount = categoryApps.filter((a) => this.selectedApps.has(a.id)).length
129
+ const countStr = selectedCount > 0 ? `(${selectedCount})` : ""
130
+ return {
131
+ name: `${cat.short}${countStr}`,
132
+ value: cat.id,
133
+ description: "", // Required by TabSelectOption
134
+ }
135
+ })
136
+ }
137
+
138
+ private updateAppList() {
139
+ const category = CATEGORY_ORDER[this.currentCategoryIndex]
140
+ const apps = getAppsByCategory()[category.id] || []
141
+
142
+ const options = apps.map((app) => ({
143
+ name: `${this.selectedApps.has(app.id) ? "[✓]" : "[ ]"} ${app.name}`,
144
+ description: `Port ${app.defaultPort} - ${app.description}`,
145
+ }))
146
+
147
+ this.appList.options = options
148
+ }
149
+
150
+ private updateWarnings() {
151
+ // Clear existing
152
+ const children = this.warningBox.getChildren()
153
+ children.forEach((c) => this.warningBox.remove(c.id))
154
+
155
+ const warnings = this.getConflictWarnings()
156
+
157
+ warnings.forEach((w, i) => {
158
+ this.warningBox.add(
159
+ new TextRenderable(this._renderer, {
160
+ id: `warning-${i}`,
161
+ content: `⚠ ${w}`,
162
+ fg: "#ffaa00",
163
+ })
164
+ )
165
+ })
166
+ }
167
+
168
+ private getConflictWarnings(): string[] {
169
+ const warnings: string[] = []
170
+ const check = (list: string[], msg: string) => {
171
+ const found = list.filter((id) => this.selectedApps.has(id as AppId))
172
+ if (found.length > 1) warnings.push(`${msg}: ${found.join(", ")}`)
173
+ }
174
+
175
+ check(["homarr", "heimdall", "homepage"], "Multiple dashboards")
176
+ check(["plex", "jellyfin"], "Multiple media servers")
177
+ check(["overseerr", "jellyseerr"], "Multiple request managers")
178
+ check(["prowlarr", "jackett"], "Multiple indexers")
179
+
180
+ return warnings
181
+ }
182
+
183
+ // --- Focus Management ---
184
+
185
+ focus() {
186
+ if (this.activeComponent === "tabs") {
187
+ // Don't focus tabs to avoid double key handling
188
+ this.appList.blur()
189
+ } else {
190
+ this.appList.focus()
191
+ }
192
+ }
193
+
194
+ handleKey(key: KeyEvent): boolean {
195
+ // Return true if handled
196
+ // Navigation between internal components
197
+ if (this.activeComponent === "tabs") {
198
+ if (key.name === "down") {
199
+ this.activeComponent = "list"
200
+ this.tabs.blur()
201
+ this.appList.focus()
202
+ return true
203
+ }
204
+ if (key.name === "left") {
205
+ this.switchCategory(-1)
206
+ return true
207
+ }
208
+ if (key.name === "right") {
209
+ this.switchCategory(1)
210
+ return true
211
+ }
212
+ } else {
213
+ // List active
214
+ if (key.name === "up" && this.appList.selectedIndex === 0) {
215
+ this.activeComponent = "tabs"
216
+ this.appList.blur()
217
+ return true
218
+ }
219
+
220
+ // Allow switching category from list too?
221
+ if (key.name === "left" || key.name === "[") {
222
+ this.switchCategory(-1)
223
+ return true
224
+ }
225
+ if (key.name === "right" || key.name === "]") {
226
+ this.switchCategory(1)
227
+ return true
228
+ }
229
+ }
230
+
231
+ return false
232
+ }
233
+
234
+ private toggleFocus() {
235
+ if (this.activeComponent === "tabs") {
236
+ this.activeComponent = "list"
237
+ this.appList.focus()
238
+ } else {
239
+ this.activeComponent = "tabs"
240
+ this.appList.blur()
241
+ }
242
+ }
243
+
244
+ private switchCategory(delta: number) {
245
+ let newIndex = this.currentCategoryIndex + delta
246
+ if (newIndex < 0) newIndex = 0
247
+ if (newIndex >= CATEGORY_ORDER.length) newIndex = CATEGORY_ORDER.length - 1
248
+
249
+ if (newIndex !== this.currentCategoryIndex) {
250
+ this.currentCategoryIndex = newIndex
251
+ // Sync Tabs
252
+ this.tabs.setSelectedIndex(newIndex)
253
+ this.updateAppList()
254
+ }
255
+ }
256
+ }
@@ -0,0 +1,91 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextareaRenderable,
4
+ TextRenderable,
5
+ CliRenderer,
6
+ RenderContext,
7
+ BoxOptions,
8
+ RGBA,
9
+ KeyEvent,
10
+ } from "@opentui/core"
11
+
12
+ export interface FileEditorOptions extends BoxOptions {
13
+ filename: string
14
+ initialContent: string
15
+ onSave: (content: string) => void
16
+ onCancel: () => void
17
+ }
18
+
19
+ export class FileEditor extends BoxRenderable {
20
+ private textarea: TextareaRenderable
21
+ private helpText: TextRenderable
22
+ private onSave: (content: string) => void
23
+ private onCancel: () => void
24
+ private filename: string
25
+ private renderer: CliRenderer
26
+ private keyHandler: ((k: KeyEvent) => void) | null = null
27
+
28
+ constructor(renderer: CliRenderer | RenderContext, options: FileEditorOptions) {
29
+ super(renderer, {
30
+ ...options,
31
+ border: true,
32
+ borderStyle: "double",
33
+ title: `Editing: ${options.filename}`,
34
+ titleAlignment: "center",
35
+ flexDirection: "column",
36
+ })
37
+
38
+ this.renderer = renderer as CliRenderer
39
+ this.onSave = options.onSave
40
+ this.onCancel = options.onCancel
41
+ this.filename = options.filename
42
+
43
+ this.textarea = new TextareaRenderable(renderer, {
44
+ width: "100%",
45
+ flexGrow: 1, // fill remaining space
46
+ initialValue: options.initialContent,
47
+ showCursor: true,
48
+ wrapMode: "none",
49
+ })
50
+ this.add(this.textarea)
51
+
52
+ // Help Footer
53
+ this.helpText = new TextRenderable(renderer, {
54
+ content: "Ctrl+S: Save | ESC: Cancel",
55
+ width: "100%",
56
+ height: 1,
57
+ fg: RGBA.fromHex("#888888"),
58
+ })
59
+ this.add(this.helpText)
60
+
61
+ // Use global key handler for reliability
62
+ this.keyHandler = (key: KeyEvent) => {
63
+ // If we are not visible or destroyed, don't handle keys
64
+ if (this.isDestroyed || !this.visible) return
65
+
66
+ if (key.name === "s" && key.ctrl) {
67
+ this.onSave(this.textarea.plainText)
68
+ } else if (key.name === "escape") {
69
+ this.onCancel()
70
+ }
71
+ }
72
+
73
+ this.renderer.keyInput.on("keypress", this.keyHandler)
74
+ }
75
+
76
+ override destroy(): void {
77
+ if (this.keyHandler) {
78
+ this.renderer.keyInput.off("keypress", this.keyHandler)
79
+ this.keyHandler = null
80
+ }
81
+ super.destroy()
82
+ }
83
+
84
+ focus() {
85
+ this.textarea.focus()
86
+ }
87
+
88
+ getValue(): string {
89
+ return this.textarea.plainText
90
+ }
91
+ }