@muhammedaksam/easiarr 0.6.2 → 0.7.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.
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Homepage Setup Screen
3
+ * Configure Homepage dashboard with enabled apps
4
+ */
5
+
6
+ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
7
+ import { createPageLayout } from "../components/PageLayout"
8
+ import { EasiarrConfig } from "../../config/schema"
9
+ import { saveHomepageConfig, generateServicesYaml } from "../../config/homepage-config"
10
+ import { getApp } from "../../apps/registry"
11
+
12
+ interface SetupResult {
13
+ name: string
14
+ status: "pending" | "configuring" | "success" | "error" | "skipped"
15
+ message?: string
16
+ }
17
+
18
+ type Step = "menu" | "generating" | "preview" | "done"
19
+
20
+ export class HomepageSetup extends BoxRenderable {
21
+ private config: EasiarrConfig
22
+ private cliRenderer: CliRenderer
23
+ private onBack: () => void
24
+ private keyHandler!: (key: KeyEvent) => void
25
+ private results: SetupResult[] = []
26
+ private currentStep: Step = "menu"
27
+ private contentBox!: BoxRenderable
28
+ private pageContainer!: BoxRenderable
29
+ private menuIndex = 0
30
+ private previewContent = ""
31
+
32
+ constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
33
+ const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
34
+ title: "Homepage Setup",
35
+ stepInfo: "Configure Dashboard",
36
+ footerHint: [
37
+ { type: "key", key: "↑↓", value: "Navigate" },
38
+ { type: "key", key: "Enter", value: "Select" },
39
+ { type: "key", key: "Esc", value: "Back" },
40
+ ],
41
+ })
42
+ super(cliRenderer, { width: "100%", height: "100%" })
43
+ this.add(pageContainer)
44
+
45
+ this.config = config
46
+ this.cliRenderer = cliRenderer
47
+ this.onBack = onBack
48
+ this.contentBox = contentBox
49
+ this.pageContainer = pageContainer
50
+
51
+ this.initKeyHandler()
52
+ this.refreshContent()
53
+ }
54
+
55
+ private initKeyHandler(): void {
56
+ this.keyHandler = (key: KeyEvent) => {
57
+ if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
58
+ if (this.currentStep === "menu") {
59
+ this.cleanup()
60
+ } else {
61
+ this.currentStep = "menu"
62
+ this.refreshContent()
63
+ }
64
+ return
65
+ }
66
+
67
+ if (this.currentStep === "menu") {
68
+ this.handleMenuKeys(key)
69
+ } else if (this.currentStep === "preview" || this.currentStep === "done") {
70
+ if (key.name === "return" || key.name === "escape") {
71
+ this.currentStep = "menu"
72
+ this.refreshContent()
73
+ }
74
+ }
75
+ }
76
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
77
+ }
78
+
79
+ private handleMenuKeys(key: KeyEvent): void {
80
+ const menuItems = this.getMenuItems()
81
+
82
+ if (key.name === "up" && this.menuIndex > 0) {
83
+ this.menuIndex--
84
+ this.refreshContent()
85
+ } else if (key.name === "down" && this.menuIndex < menuItems.length - 1) {
86
+ this.menuIndex++
87
+ this.refreshContent()
88
+ } else if (key.name === "return") {
89
+ this.executeMenuItem(this.menuIndex)
90
+ }
91
+ }
92
+
93
+ private getMenuItems(): { name: string; description: string; action: () => void }[] {
94
+ return [
95
+ {
96
+ name: "📊 Generate Services",
97
+ description: "Create services.yaml with all enabled apps",
98
+ action: () => this.generateServices(),
99
+ },
100
+ {
101
+ name: "👁️ Preview Config",
102
+ description: "Preview generated services.yaml",
103
+ action: () => this.previewServices(),
104
+ },
105
+ {
106
+ name: "📋 Show Enabled Apps",
107
+ description: "List apps that will be added to Homepage",
108
+ action: () => this.showEnabledApps(),
109
+ },
110
+ {
111
+ name: "↩️ Back",
112
+ description: "Return to main menu",
113
+ action: () => this.cleanup(),
114
+ },
115
+ ]
116
+ }
117
+
118
+ private executeMenuItem(index: number): void {
119
+ const items = this.getMenuItems()
120
+ if (index >= 0 && index < items.length) {
121
+ items[index].action()
122
+ }
123
+ }
124
+
125
+ private async generateServices(): Promise<void> {
126
+ this.currentStep = "generating"
127
+ this.results = [{ name: "services.yaml", status: "configuring" }]
128
+ this.refreshContent()
129
+
130
+ try {
131
+ const paths = await saveHomepageConfig(this.config)
132
+ this.results[0].status = "success"
133
+ this.results[0].message = `Saved to ${paths.services}`
134
+ } catch (error) {
135
+ this.results[0].status = "error"
136
+ this.results[0].message = error instanceof Error ? error.message : String(error)
137
+ }
138
+
139
+ this.currentStep = "done"
140
+ this.refreshContent()
141
+ }
142
+
143
+ private async previewServices(): Promise<void> {
144
+ this.previewContent = await generateServicesYaml(this.config)
145
+ this.currentStep = "preview"
146
+ this.refreshContent()
147
+ }
148
+
149
+ private showEnabledApps(): void {
150
+ const apps = this.config.apps.filter((a) => a.enabled && a.id !== "homepage")
151
+
152
+ this.results = apps.map((app) => {
153
+ const def = getApp(app.id)
154
+ const hasWidget = def?.homepage?.widget ? "📊" : "📌"
155
+ return {
156
+ name: `${hasWidget} ${def?.name || app.id}`,
157
+ status: "success" as const,
158
+ message: def?.description,
159
+ }
160
+ })
161
+
162
+ if (this.results.length === 0) {
163
+ this.results = [{ name: "No apps enabled", status: "skipped", message: "Enable apps first" }]
164
+ }
165
+
166
+ this.currentStep = "done"
167
+ this.refreshContent()
168
+ }
169
+
170
+ private refreshContent(): void {
171
+ this.contentBox.getChildren().forEach((child) => child.destroy())
172
+
173
+ if (this.currentStep === "menu") {
174
+ this.renderMenu()
175
+ } else if (this.currentStep === "preview") {
176
+ this.renderPreview()
177
+ } else {
178
+ this.renderResults()
179
+ }
180
+ }
181
+
182
+ private renderMenu(): void {
183
+ this.contentBox.add(
184
+ new TextRenderable(this.cliRenderer, {
185
+ content: "Configure Homepage dashboard with your enabled apps:\n\n",
186
+ fg: "#aaaaaa",
187
+ })
188
+ )
189
+
190
+ this.getMenuItems().forEach((item, idx) => {
191
+ const pointer = idx === this.menuIndex ? "→ " : " "
192
+ const fg = idx === this.menuIndex ? "#50fa7b" : "#8be9fd"
193
+
194
+ this.contentBox.add(
195
+ new TextRenderable(this.cliRenderer, {
196
+ content: `${pointer}${item.name}\n`,
197
+ fg,
198
+ })
199
+ )
200
+ this.contentBox.add(
201
+ new TextRenderable(this.cliRenderer, {
202
+ content: ` ${item.description}\n\n`,
203
+ fg: "#6272a4",
204
+ })
205
+ )
206
+ })
207
+ }
208
+
209
+ private renderPreview(): void {
210
+ this.contentBox.add(
211
+ new TextRenderable(this.cliRenderer, {
212
+ content: "Preview: services.yaml\n",
213
+ fg: "#50fa7b",
214
+ })
215
+ )
216
+ this.contentBox.add(
217
+ new TextRenderable(this.cliRenderer, {
218
+ content: "─".repeat(40) + "\n",
219
+ fg: "#555555",
220
+ })
221
+ )
222
+
223
+ // Show preview (truncated)
224
+ const lines = this.previewContent.split("\n").slice(0, 30)
225
+ for (const line of lines) {
226
+ this.contentBox.add(
227
+ new TextRenderable(this.cliRenderer, {
228
+ content: line + "\n",
229
+ fg: line.startsWith("#") ? "#6272a4" : line.endsWith(":") ? "#8be9fd" : "#f8f8f2",
230
+ })
231
+ )
232
+ }
233
+
234
+ if (this.previewContent.split("\n").length > 30) {
235
+ this.contentBox.add(
236
+ new TextRenderable(this.cliRenderer, {
237
+ content: "\n... (truncated)\n",
238
+ fg: "#6272a4",
239
+ })
240
+ )
241
+ }
242
+
243
+ this.contentBox.add(
244
+ new TextRenderable(this.cliRenderer, {
245
+ content: "\nPress Enter or Esc to go back",
246
+ fg: "#6272a4",
247
+ })
248
+ )
249
+ }
250
+
251
+ private renderResults(): void {
252
+ const headerText = this.currentStep === "done" ? "Results:\n\n" : "Generating...\n\n"
253
+ this.contentBox.add(
254
+ new TextRenderable(this.cliRenderer, {
255
+ content: headerText,
256
+ fg: this.currentStep === "done" ? "#50fa7b" : "#f1fa8c",
257
+ })
258
+ )
259
+
260
+ for (const result of this.results) {
261
+ let status = ""
262
+ let fg = "#aaaaaa"
263
+ switch (result.status) {
264
+ case "pending":
265
+ status = "⏳"
266
+ break
267
+ case "configuring":
268
+ status = "🔄"
269
+ fg = "#f1fa8c"
270
+ break
271
+ case "success":
272
+ status = "✓"
273
+ fg = "#50fa7b"
274
+ break
275
+ case "error":
276
+ status = "✗"
277
+ fg = "#ff5555"
278
+ break
279
+ case "skipped":
280
+ status = "⊘"
281
+ fg = "#6272a4"
282
+ break
283
+ }
284
+
285
+ let content = `${status} ${result.name}`
286
+ if (result.message) {
287
+ content += ` - ${result.message}`
288
+ }
289
+
290
+ this.contentBox.add(new TextRenderable(this.cliRenderer, { content: content + "\n", fg }))
291
+ }
292
+
293
+ this.contentBox.add(
294
+ new TextRenderable(this.cliRenderer, {
295
+ content: "\nPress Enter or Esc to continue...",
296
+ fg: "#6272a4",
297
+ })
298
+ )
299
+ }
300
+
301
+ private cleanup(): void {
302
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
303
+ this.destroy()
304
+ this.onBack()
305
+ }
306
+ }
@@ -16,6 +16,7 @@ import { ProwlarrSetup } from "./ProwlarrSetup"
16
16
  import { QBittorrentSetup } from "./QBittorrentSetup"
17
17
  import { FullAutoSetup } from "./FullAutoSetup"
18
18
  import { MonitorDashboard } from "./MonitorDashboard"
19
+ import { HomepageSetup } from "./HomepageSetup"
19
20
 
20
21
  export class MainMenu {
21
22
  private renderer: RenderContext
@@ -129,6 +130,10 @@ export class MainMenu {
129
130
  name: "📊 Monitor Dashboard",
130
131
  description: "Configure app health monitoring",
131
132
  },
133
+ {
134
+ name: "🏠 Homepage Setup",
135
+ description: "Generate Homepage dashboard config",
136
+ },
132
137
  { name: "❌ Exit", description: "Close easiarr" },
133
138
  ],
134
139
  })
@@ -227,7 +232,18 @@ export class MainMenu {
227
232
  this.container.add(monitor)
228
233
  break
229
234
  }
230
- case 11:
235
+ case 11: {
236
+ // Homepage Setup
237
+ this.menu.blur()
238
+ this.page.visible = false
239
+ const homepageSetup = new HomepageSetup(this.renderer as CliRenderer, this.config, () => {
240
+ this.page.visible = true
241
+ this.menu.focus()
242
+ })
243
+ this.container.add(homepageSetup)
244
+ break
245
+ }
246
+ case 12:
231
247
  process.exit(0)
232
248
  break
233
249
  }
@@ -40,6 +40,8 @@ export class QuickSetup {
40
40
  "jellyfin",
41
41
  "jellyseerr",
42
42
  "flaresolverr",
43
+ "homepage",
44
+ "easiarr-status",
43
45
  ])
44
46
 
45
47
  private rootDir: string = `${homedir()}/media`
package/src/utils/env.ts CHANGED
@@ -5,8 +5,31 @@
5
5
 
6
6
  import { existsSync, readFileSync } from "node:fs"
7
7
  import { writeFile, readFile } from "node:fs/promises"
8
+ import { networkInterfaces } from "node:os"
8
9
  import { getComposePath } from "../config/manager"
9
10
 
11
+ /**
12
+ * Get the local IP address of the Docker host
13
+ * Returns the first non-internal IPv4 address found
14
+ */
15
+ export function getLocalIp(): string {
16
+ const nets = networkInterfaces()
17
+
18
+ for (const name of Object.keys(nets)) {
19
+ const interfaces = nets[name]
20
+ if (!interfaces) continue
21
+
22
+ for (const net of interfaces) {
23
+ // Skip internal (loopback) and non-IPv4 addresses
24
+ if (net.family === "IPv4" && !net.internal) {
25
+ return net.address
26
+ }
27
+ }
28
+ }
29
+
30
+ return "localhost"
31
+ }
32
+
10
33
  /**
11
34
  * Get the path to the .env file
12
35
  */
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Update Checker
3
+ * Check GitHub releases for new easiarr versions
4
+ */
5
+
6
+ import { VersionInfo } from "../VersionInfo"
7
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import { homedir } from "node:os"
10
+
11
+ const GITHUB_REPO = "muhammedaksam/easiarr"
12
+ const CACHE_FILE = join(homedir(), ".easiarr", ".update-cache.json")
13
+ const CACHE_DURATION_MS = 24 * 60 * 60 * 1000 // 24 hours
14
+
15
+ export interface UpdateInfo {
16
+ currentVersion: string
17
+ latestVersion: string
18
+ updateAvailable: boolean
19
+ releaseUrl: string
20
+ releaseNotes?: string
21
+ publishedAt?: string
22
+ }
23
+
24
+ interface CachedUpdate {
25
+ checkedAt: string
26
+ latestVersion: string
27
+ releaseUrl: string
28
+ releaseNotes?: string
29
+ publishedAt?: string
30
+ }
31
+
32
+ /**
33
+ * Get cached update info if still valid
34
+ */
35
+ function getCachedUpdate(): CachedUpdate | null {
36
+ if (!existsSync(CACHE_FILE)) return null
37
+
38
+ try {
39
+ const content = readFileSync(CACHE_FILE, "utf-8")
40
+ const cached = JSON.parse(content) as CachedUpdate
41
+
42
+ const checkedAt = new Date(cached.checkedAt).getTime()
43
+ const now = Date.now()
44
+
45
+ // Cache still valid
46
+ if (now - checkedAt < CACHE_DURATION_MS) {
47
+ return cached
48
+ }
49
+ } catch {
50
+ // Ignore cache errors
51
+ }
52
+
53
+ return null
54
+ }
55
+
56
+ /**
57
+ * Save update info to cache
58
+ */
59
+ function cacheUpdate(info: Omit<CachedUpdate, "checkedAt">): void {
60
+ try {
61
+ const cached: CachedUpdate = {
62
+ ...info,
63
+ checkedAt: new Date().toISOString(),
64
+ }
65
+ writeFileSync(CACHE_FILE, JSON.stringify(cached, null, 2), "utf-8")
66
+ } catch {
67
+ // Ignore cache write errors
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Compare semver versions
73
+ * Returns true if v2 is newer than v1
74
+ */
75
+ function isNewerVersion(v1: string, v2: string): boolean {
76
+ const normalize = (v: string) => v.replace(/^v/, "")
77
+ const parts1 = normalize(v1).split(".").map(Number)
78
+ const parts2 = normalize(v2).split(".").map(Number)
79
+
80
+ for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
81
+ const p1 = parts1[i] || 0
82
+ const p2 = parts2[i] || 0
83
+ if (p2 > p1) return true
84
+ if (p2 < p1) return false
85
+ }
86
+
87
+ return false
88
+ }
89
+
90
+ /**
91
+ * Fetch latest release from GitHub API
92
+ */
93
+ async function fetchLatestRelease(): Promise<{
94
+ version: string
95
+ url: string
96
+ notes?: string
97
+ publishedAt?: string
98
+ } | null> {
99
+ try {
100
+ const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
101
+ headers: {
102
+ Accept: "application/vnd.github.v3+json",
103
+ "User-Agent": "easiarr-update-checker",
104
+ },
105
+ })
106
+
107
+ if (!response.ok) {
108
+ return null
109
+ }
110
+
111
+ const data = (await response.json()) as {
112
+ tag_name: string
113
+ html_url: string
114
+ body?: string
115
+ published_at?: string
116
+ }
117
+
118
+ return {
119
+ version: data.tag_name,
120
+ url: data.html_url,
121
+ notes: data.body,
122
+ publishedAt: data.published_at,
123
+ }
124
+ } catch {
125
+ return null
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Check for updates
131
+ * Uses cache to avoid repeated API calls
132
+ */
133
+ export async function checkForUpdates(): Promise<UpdateInfo> {
134
+ const currentVersion = VersionInfo.version
135
+
136
+ // Check cache first
137
+ const cached = getCachedUpdate()
138
+ if (cached) {
139
+ return {
140
+ currentVersion,
141
+ latestVersion: cached.latestVersion,
142
+ updateAvailable: isNewerVersion(currentVersion, cached.latestVersion),
143
+ releaseUrl: cached.releaseUrl,
144
+ releaseNotes: cached.releaseNotes,
145
+ publishedAt: cached.publishedAt,
146
+ }
147
+ }
148
+
149
+ // Fetch from GitHub
150
+ const release = await fetchLatestRelease()
151
+
152
+ if (!release) {
153
+ return {
154
+ currentVersion,
155
+ latestVersion: currentVersion,
156
+ updateAvailable: false,
157
+ releaseUrl: `https://github.com/${GITHUB_REPO}/releases`,
158
+ }
159
+ }
160
+
161
+ // Cache the result
162
+ cacheUpdate({
163
+ latestVersion: release.version,
164
+ releaseUrl: release.url,
165
+ releaseNotes: release.notes,
166
+ publishedAt: release.publishedAt,
167
+ })
168
+
169
+ return {
170
+ currentVersion,
171
+ latestVersion: release.version,
172
+ updateAvailable: isNewerVersion(currentVersion, release.version),
173
+ releaseUrl: release.url,
174
+ releaseNotes: release.notes,
175
+ publishedAt: release.publishedAt,
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Force refresh update check (ignores cache)
181
+ */
182
+ export async function forceCheckForUpdates(): Promise<UpdateInfo> {
183
+ const currentVersion = VersionInfo.version
184
+ const release = await fetchLatestRelease()
185
+
186
+ if (!release) {
187
+ return {
188
+ currentVersion,
189
+ latestVersion: currentVersion,
190
+ updateAvailable: false,
191
+ releaseUrl: `https://github.com/${GITHUB_REPO}/releases`,
192
+ }
193
+ }
194
+
195
+ cacheUpdate({
196
+ latestVersion: release.version,
197
+ releaseUrl: release.url,
198
+ releaseNotes: release.notes,
199
+ publishedAt: release.publishedAt,
200
+ })
201
+
202
+ return {
203
+ currentVersion,
204
+ latestVersion: release.version,
205
+ updateAvailable: isNewerVersion(currentVersion, release.version),
206
+ releaseUrl: release.url,
207
+ releaseNotes: release.notes,
208
+ publishedAt: release.publishedAt,
209
+ }
210
+ }