@muhammedaksam/easiarr 0.7.0 → 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.
- package/package.json +1 -1
- package/src/apps/registry.ts +57 -5
- package/src/compose/generator.ts +2 -1
- package/src/config/homepage-config.ts +247 -0
- package/src/config/manager.ts +31 -1
- package/src/config/schema.ts +13 -0
- package/src/ui/App.ts +15 -1
- package/src/ui/components/UpdateNotification.ts +101 -0
- package/src/ui/screens/HomepageSetup.ts +306 -0
- package/src/ui/screens/MainMenu.ts +17 -1
- package/src/ui/screens/QuickSetup.ts +2 -0
- package/src/utils/env.ts +23 -0
- package/src/utils/update-checker.ts +210 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
package/src/apps/registry.ts
CHANGED
|
@@ -29,7 +29,8 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
29
29
|
path: "/data/media/movies",
|
|
30
30
|
apiVersion: "v3",
|
|
31
31
|
},
|
|
32
|
-
prowlarrCategoryIds: [2000,
|
|
32
|
+
prowlarrCategoryIds: [2000], // Movies
|
|
33
|
+
homepage: { icon: "radarr.png", widget: "radarr" },
|
|
33
34
|
},
|
|
34
35
|
|
|
35
36
|
sonarr: {
|
|
@@ -53,7 +54,8 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
53
54
|
path: "/data/media/tv",
|
|
54
55
|
apiVersion: "v3",
|
|
55
56
|
},
|
|
56
|
-
prowlarrCategoryIds: [5000,
|
|
57
|
+
prowlarrCategoryIds: [5000], // TV
|
|
58
|
+
homepage: { icon: "sonarr.png", widget: "sonarr" },
|
|
57
59
|
},
|
|
58
60
|
|
|
59
61
|
lidarr: {
|
|
@@ -75,7 +77,8 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
75
77
|
path: "/data/media/music",
|
|
76
78
|
apiVersion: "v1",
|
|
77
79
|
},
|
|
78
|
-
prowlarrCategoryIds: [3000,
|
|
80
|
+
prowlarrCategoryIds: [3000], // Audio
|
|
81
|
+
homepage: { icon: "lidarr.png", widget: "lidarr" },
|
|
79
82
|
},
|
|
80
83
|
|
|
81
84
|
readarr: {
|
|
@@ -97,11 +100,12 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
97
100
|
path: "/data/media/books",
|
|
98
101
|
apiVersion: "v1",
|
|
99
102
|
},
|
|
100
|
-
prowlarrCategoryIds: [7000,
|
|
103
|
+
prowlarrCategoryIds: [7000], // Books
|
|
101
104
|
arch: {
|
|
102
105
|
deprecated: ["arm64", "arm32"],
|
|
103
106
|
warning: "Readarr is deprecated - no ARM64 support (project abandoned by upstream)",
|
|
104
107
|
},
|
|
108
|
+
homepage: { icon: "readarr.png", widget: "readarr" },
|
|
105
109
|
},
|
|
106
110
|
|
|
107
111
|
bazarr: {
|
|
@@ -122,6 +126,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
122
126
|
parser: "yaml",
|
|
123
127
|
selector: "auth.apikey",
|
|
124
128
|
},
|
|
129
|
+
homepage: { icon: "bazarr.png", widget: "bazarr" },
|
|
125
130
|
},
|
|
126
131
|
|
|
127
132
|
mylar3: {
|
|
@@ -166,7 +171,8 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
166
171
|
path: "/data/media/adult",
|
|
167
172
|
apiVersion: "v3",
|
|
168
173
|
},
|
|
169
|
-
prowlarrCategoryIds: [6000,
|
|
174
|
+
prowlarrCategoryIds: [6000], // XXX
|
|
175
|
+
homepage: { icon: "whisparr.png", widget: "sonarr" }, // Uses sonarr widget type
|
|
170
176
|
},
|
|
171
177
|
|
|
172
178
|
audiobookshelf: {
|
|
@@ -204,6 +210,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
204
210
|
parser: "regex",
|
|
205
211
|
selector: "<ApiKey>(.*?)</ApiKey>",
|
|
206
212
|
},
|
|
213
|
+
homepage: { icon: "prowlarr.png", widget: "prowlarr" },
|
|
207
214
|
},
|
|
208
215
|
|
|
209
216
|
jackett: {
|
|
@@ -268,6 +275,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
268
275
|
},
|
|
269
276
|
],
|
|
270
277
|
trashGuide: "docs/Downloaders/qBittorrent/",
|
|
278
|
+
homepage: { icon: "qbittorrent.png", widget: "qbittorrent" },
|
|
271
279
|
},
|
|
272
280
|
|
|
273
281
|
sabnzbd: {
|
|
@@ -288,6 +296,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
288
296
|
parser: "regex",
|
|
289
297
|
selector: "api_key\\s*=\\s*(.+)",
|
|
290
298
|
},
|
|
299
|
+
homepage: { icon: "sabnzbd.png", widget: "sabnzbd" },
|
|
291
300
|
},
|
|
292
301
|
|
|
293
302
|
// === MEDIA SERVERS ===
|
|
@@ -309,6 +318,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
309
318
|
parser: "regex",
|
|
310
319
|
selector: 'PlexOnlineToken="([^"]+)"',
|
|
311
320
|
},
|
|
321
|
+
homepage: { icon: "plex.png", widget: "plex" },
|
|
312
322
|
},
|
|
313
323
|
|
|
314
324
|
jellyfin: {
|
|
@@ -321,6 +331,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
321
331
|
puid: 0,
|
|
322
332
|
pgid: 13000,
|
|
323
333
|
volumes: (root) => [`${root}/config/jellyfin:/config`, `${root}/data/media:/data/media`],
|
|
334
|
+
homepage: { icon: "jellyfin.png", widget: "jellyfin" },
|
|
324
335
|
},
|
|
325
336
|
|
|
326
337
|
tautulli: {
|
|
@@ -339,6 +350,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
339
350
|
parser: "regex",
|
|
340
351
|
selector: "api_key\\s*=\\s*(.+)",
|
|
341
352
|
},
|
|
353
|
+
homepage: { icon: "tautulli.png", widget: "tautulli" },
|
|
342
354
|
},
|
|
343
355
|
|
|
344
356
|
tdarr: {
|
|
@@ -376,6 +388,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
376
388
|
parser: "json",
|
|
377
389
|
selector: "main.apiKey",
|
|
378
390
|
},
|
|
391
|
+
homepage: { icon: "overseerr.png", widget: "overseerr" },
|
|
379
392
|
},
|
|
380
393
|
|
|
381
394
|
jellyseerr: {
|
|
@@ -436,6 +449,10 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
436
449
|
puid: 0,
|
|
437
450
|
pgid: 0,
|
|
438
451
|
volumes: (root) => [`${root}/config/homepage:/app/config`, "/var/run/docker.sock:/var/run/docker.sock"],
|
|
452
|
+
environment: {
|
|
453
|
+
HOMEPAGE_ALLOWED_HOSTS:
|
|
454
|
+
"homepage,homepage.${CLOUDFLARE_DNS_ZONE},${CLOUDFLARE_DNS_ZONE},localhost,${LOCAL_DOCKER_IP}",
|
|
455
|
+
},
|
|
439
456
|
},
|
|
440
457
|
|
|
441
458
|
// === UTILITIES ===
|
|
@@ -450,6 +467,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
450
467
|
pgid: 0,
|
|
451
468
|
volumes: (root) => [`${root}/config/portainer:/data`, "/var/run/docker.sock:/var/run/docker.sock"],
|
|
452
469
|
minPasswordLength: 12, // Portainer requires minimum 12 character password
|
|
470
|
+
homepage: { icon: "portainer.png", widget: "portainer" },
|
|
453
471
|
},
|
|
454
472
|
|
|
455
473
|
huntarr: {
|
|
@@ -562,6 +580,31 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
562
580
|
volumes: (root) => [`${root}/config/ddns-updater:/data`],
|
|
563
581
|
},
|
|
564
582
|
|
|
583
|
+
"easiarr-status": {
|
|
584
|
+
id: "easiarr-status",
|
|
585
|
+
name: "Easiarr Status",
|
|
586
|
+
description: "Exposes Easiarr version for Homepage dashboard",
|
|
587
|
+
category: "utility",
|
|
588
|
+
defaultPort: 3009,
|
|
589
|
+
image: "halverneus/static-file-server:latest",
|
|
590
|
+
puid: 0,
|
|
591
|
+
pgid: 0,
|
|
592
|
+
volumes: (root) => [`${root}/.easiarr:/web:ro`],
|
|
593
|
+
environment: {
|
|
594
|
+
FOLDER: "/web",
|
|
595
|
+
PORT: "3009",
|
|
596
|
+
CORS: "true",
|
|
597
|
+
},
|
|
598
|
+
homepage: {
|
|
599
|
+
icon: "mdi-docker",
|
|
600
|
+
widget: "customapi",
|
|
601
|
+
widgetFields: {
|
|
602
|
+
url: "http://easiarr-status:3009/config.json",
|
|
603
|
+
mappings: JSON.stringify([{ field: "version", label: "Installed" }]),
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
|
|
565
608
|
// === VPN ===
|
|
566
609
|
gluetun: {
|
|
567
610
|
id: "gluetun",
|
|
@@ -608,6 +651,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
608
651
|
mask: true,
|
|
609
652
|
},
|
|
610
653
|
],
|
|
654
|
+
homepage: { icon: "gluetun.png", widget: "gluetun" },
|
|
611
655
|
},
|
|
612
656
|
|
|
613
657
|
// === MONITORING ===
|
|
@@ -621,6 +665,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
621
665
|
puid: 0,
|
|
622
666
|
pgid: 13000,
|
|
623
667
|
volumes: (root) => [`${root}/config/grafana:/var/lib/grafana`],
|
|
668
|
+
homepage: { icon: "grafana.png", widget: "grafana" },
|
|
624
669
|
},
|
|
625
670
|
|
|
626
671
|
prometheus: {
|
|
@@ -633,6 +678,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
633
678
|
puid: 0,
|
|
634
679
|
pgid: 13000,
|
|
635
680
|
volumes: (root) => [`${root}/config/prometheus:/prometheus`],
|
|
681
|
+
homepage: { icon: "prometheus.png" }, // No widget, just icon
|
|
636
682
|
},
|
|
637
683
|
|
|
638
684
|
dozzle: {
|
|
@@ -660,6 +706,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
660
706
|
puid: 0,
|
|
661
707
|
pgid: 0,
|
|
662
708
|
volumes: (root) => [`${root}/config/uptime-kuma:/app/data`, "/var/run/docker.sock:/var/run/docker.sock"],
|
|
709
|
+
homepage: { icon: "uptime-kuma.png", widget: "uptimekuma" },
|
|
663
710
|
},
|
|
664
711
|
|
|
665
712
|
// === INFRASTRUCTURE ===
|
|
@@ -690,6 +737,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
690
737
|
required: true,
|
|
691
738
|
},
|
|
692
739
|
],
|
|
740
|
+
homepage: { icon: "traefik.png", widget: "traefik" },
|
|
693
741
|
},
|
|
694
742
|
|
|
695
743
|
"traefik-certs-dumper": {
|
|
@@ -715,6 +763,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
715
763
|
puid: 0,
|
|
716
764
|
pgid: 0,
|
|
717
765
|
volumes: (root) => [`${root}/config/crowdsec:/etc/crowdsec`, "/var/run/docker.sock:/var/run/docker.sock:ro"],
|
|
766
|
+
homepage: { icon: "crowdsec.png", widget: "crowdsec" },
|
|
718
767
|
},
|
|
719
768
|
|
|
720
769
|
headscale: {
|
|
@@ -727,6 +776,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
727
776
|
puid: 0,
|
|
728
777
|
pgid: 0,
|
|
729
778
|
volumes: (root) => [`${root}/config/headscale:/etc/headscale`, `${root}/config/headscale/data:/var/lib/headscale`],
|
|
779
|
+
homepage: { icon: "headscale.png", widget: "headscale" },
|
|
730
780
|
},
|
|
731
781
|
|
|
732
782
|
headplane: {
|
|
@@ -762,6 +812,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
762
812
|
mask: true,
|
|
763
813
|
},
|
|
764
814
|
],
|
|
815
|
+
homepage: { icon: "tailscale.png", widget: "tailscale" },
|
|
765
816
|
},
|
|
766
817
|
|
|
767
818
|
authentik: {
|
|
@@ -804,6 +855,7 @@ export const APPS: Record<AppId, AppDefinition> = {
|
|
|
804
855
|
mask: true,
|
|
805
856
|
},
|
|
806
857
|
],
|
|
858
|
+
homepage: { icon: "authentik.png", widget: "authentik" },
|
|
807
859
|
},
|
|
808
860
|
|
|
809
861
|
"authentik-worker": {
|
package/src/compose/generator.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { EasiarrConfig, AppConfig, TraefikConfig, AppId } from "../config/s
|
|
|
8
8
|
import { getComposePath } from "../config/manager"
|
|
9
9
|
import { getApp } from "../apps/registry"
|
|
10
10
|
import { generateServiceYaml } from "./templates"
|
|
11
|
-
import { updateEnv } from "../utils/env"
|
|
11
|
+
import { updateEnv, getLocalIp } from "../utils/env"
|
|
12
12
|
|
|
13
13
|
export interface ComposeService {
|
|
14
14
|
image: string
|
|
@@ -212,5 +212,6 @@ async function updateEnvFile(config: EasiarrConfig) {
|
|
|
212
212
|
PUID: config.uid.toString(),
|
|
213
213
|
PGID: config.gid.toString(),
|
|
214
214
|
UMASK: config.umask,
|
|
215
|
+
LOCAL_DOCKER_IP: getLocalIp(),
|
|
215
216
|
})
|
|
216
217
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Homepage Configuration Generator
|
|
3
|
+
* Generates services.yaml and other config files for Homepage dashboard
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFile, mkdir } from "node:fs/promises"
|
|
7
|
+
import { existsSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import type { EasiarrConfig, AppCategory } from "./schema"
|
|
10
|
+
import { APP_CATEGORIES } from "./schema"
|
|
11
|
+
import { getApp } from "../apps/registry"
|
|
12
|
+
import { CATEGORY_ORDER } from "../apps/categories"
|
|
13
|
+
import { readEnvSync, getLocalIp } from "../utils/env"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the Homepage config directory path
|
|
17
|
+
*/
|
|
18
|
+
export function getHomepageConfigPath(config: EasiarrConfig): string {
|
|
19
|
+
return join(config.rootDir, "config", "homepage")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Homepage service entry
|
|
24
|
+
*/
|
|
25
|
+
interface HomepageService {
|
|
26
|
+
href: string
|
|
27
|
+
icon?: string
|
|
28
|
+
description?: string
|
|
29
|
+
ping?: string
|
|
30
|
+
widget?: {
|
|
31
|
+
type: string
|
|
32
|
+
url: string
|
|
33
|
+
key?: string
|
|
34
|
+
[key: string]: unknown
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generate services.yaml content from enabled apps
|
|
40
|
+
*/
|
|
41
|
+
export async function generateServicesYaml(config: EasiarrConfig): Promise<string> {
|
|
42
|
+
const env = readEnvSync()
|
|
43
|
+
const localIp = getLocalIp()
|
|
44
|
+
|
|
45
|
+
// Group apps by category
|
|
46
|
+
const categoryGroups = new Map<AppCategory, Array<{ name: string; service: HomepageService }>>()
|
|
47
|
+
|
|
48
|
+
for (const appConfig of config.apps) {
|
|
49
|
+
if (!appConfig.enabled) continue
|
|
50
|
+
|
|
51
|
+
const appDef = getApp(appConfig.id)
|
|
52
|
+
if (!appDef) continue
|
|
53
|
+
|
|
54
|
+
// Skip homepage itself
|
|
55
|
+
if (appDef.id === "homepage") continue
|
|
56
|
+
|
|
57
|
+
const port = appConfig.port ?? appDef.defaultPort
|
|
58
|
+
const baseUrl = `http://${localIp}:${port}`
|
|
59
|
+
|
|
60
|
+
const service: HomepageService = {
|
|
61
|
+
href: baseUrl,
|
|
62
|
+
description: appDef.description,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Add icon if defined in homepage meta
|
|
66
|
+
if (appDef.homepage?.icon) {
|
|
67
|
+
service.icon = appDef.homepage.icon
|
|
68
|
+
} else {
|
|
69
|
+
// Default to app ID as icon name
|
|
70
|
+
service.icon = `${appDef.id}.png`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Add ping for monitoring
|
|
74
|
+
service.ping = baseUrl
|
|
75
|
+
|
|
76
|
+
// Add widget if defined and has API key
|
|
77
|
+
if (appDef.homepage?.widget) {
|
|
78
|
+
const apiKey = env[`API_KEY_${appDef.id.toUpperCase()}`]
|
|
79
|
+
|
|
80
|
+
service.widget = {
|
|
81
|
+
type: appDef.homepage.widget,
|
|
82
|
+
url: baseUrl,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (apiKey) {
|
|
86
|
+
service.widget.key = apiKey
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Add any custom widget fields
|
|
90
|
+
if (appDef.homepage.widgetFields) {
|
|
91
|
+
Object.assign(service.widget, appDef.homepage.widgetFields)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Add to category group
|
|
96
|
+
const category = appDef.category
|
|
97
|
+
if (!categoryGroups.has(category)) {
|
|
98
|
+
categoryGroups.set(category, [])
|
|
99
|
+
}
|
|
100
|
+
categoryGroups.get(category)!.push({ name: appDef.name, service })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Build YAML output
|
|
104
|
+
let yaml = "---\n# Auto-generated by Easiarr\n# https://github.com/muhammedaksam/easiarr\n\n"
|
|
105
|
+
|
|
106
|
+
// Add Easiarr info section with two widgets - one for installed, one for latest
|
|
107
|
+
const localIpForEasiarr = localIp
|
|
108
|
+
yaml += `Easiarr:\n`
|
|
109
|
+
// Installed version from local easiarr-status container
|
|
110
|
+
yaml += ` - Installed:\n`
|
|
111
|
+
yaml += ` href: https://github.com/muhammedaksam/easiarr\n`
|
|
112
|
+
yaml += ` icon: mdi-docker\n`
|
|
113
|
+
yaml += ` description: Your current version\n`
|
|
114
|
+
yaml += ` widget:\n`
|
|
115
|
+
yaml += ` type: customapi\n`
|
|
116
|
+
yaml += ` url: http://${localIpForEasiarr}:3009/config.json\n`
|
|
117
|
+
yaml += ` refreshInterval: 3600000\n` // 1 hour
|
|
118
|
+
yaml += ` mappings:\n`
|
|
119
|
+
yaml += ` - field: version\n`
|
|
120
|
+
yaml += ` label: Version\n`
|
|
121
|
+
// Latest version from GitHub API
|
|
122
|
+
yaml += ` - Latest:\n`
|
|
123
|
+
yaml += ` href: https://github.com/muhammedaksam/easiarr/releases\n`
|
|
124
|
+
yaml += ` icon: mdi-github\n`
|
|
125
|
+
yaml += ` description: Check for updates\n`
|
|
126
|
+
yaml += ` widget:\n`
|
|
127
|
+
yaml += ` type: customapi\n`
|
|
128
|
+
yaml += ` url: https://api.github.com/repos/muhammedaksam/easiarr/releases/latest\n`
|
|
129
|
+
yaml += ` refreshInterval: 86400000\n` // 24 hours
|
|
130
|
+
yaml += ` mappings:\n`
|
|
131
|
+
yaml += ` - field: tag_name\n`
|
|
132
|
+
yaml += ` label: Version\n`
|
|
133
|
+
yaml += ` - field: published_at\n`
|
|
134
|
+
yaml += ` label: Released\n`
|
|
135
|
+
yaml += ` format: relativeDate\n`
|
|
136
|
+
yaml += `\n`
|
|
137
|
+
|
|
138
|
+
// Use CATEGORY_ORDER for consistent ordering
|
|
139
|
+
for (const { id: category } of CATEGORY_ORDER) {
|
|
140
|
+
const services = categoryGroups.get(category)
|
|
141
|
+
if (!services || services.length === 0) continue
|
|
142
|
+
|
|
143
|
+
const categoryName = APP_CATEGORIES[category]
|
|
144
|
+
yaml += `${categoryName}:\n`
|
|
145
|
+
|
|
146
|
+
for (const { name, service } of services) {
|
|
147
|
+
yaml += ` - ${name}:\n`
|
|
148
|
+
yaml += ` href: ${service.href}\n`
|
|
149
|
+
|
|
150
|
+
if (service.icon) {
|
|
151
|
+
yaml += ` icon: ${service.icon}\n`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (service.description) {
|
|
155
|
+
yaml += ` description: ${service.description}\n`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (service.ping) {
|
|
159
|
+
yaml += ` ping: ${service.ping}\n`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (service.widget) {
|
|
163
|
+
yaml += ` widget:\n`
|
|
164
|
+
yaml += ` type: ${service.widget.type}\n`
|
|
165
|
+
yaml += ` url: ${service.widget.url}\n`
|
|
166
|
+
|
|
167
|
+
if (service.widget.key) {
|
|
168
|
+
yaml += ` key: ${service.widget.key}\n`
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Add any other widget fields
|
|
172
|
+
for (const [key, value] of Object.entries(service.widget)) {
|
|
173
|
+
if (key !== "type" && key !== "url" && key !== "key") {
|
|
174
|
+
yaml += ` ${key}: ${value}\n`
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
yaml += "\n"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return yaml
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Generate settings.yaml with sensible defaults
|
|
188
|
+
*/
|
|
189
|
+
export function generateSettingsYaml(): string {
|
|
190
|
+
return `---
|
|
191
|
+
# Auto-generated by Easiarr
|
|
192
|
+
# For configuration options: https://gethomepage.dev/configs/settings/
|
|
193
|
+
|
|
194
|
+
title: Easiarr Dashboard
|
|
195
|
+
|
|
196
|
+
# Background: "Close-up Photography of Leaves With Droplets"
|
|
197
|
+
# Photo by Sohail Nachiti: https://www.pexels.com/photo/close-up-photography-of-leaves-with-droplets-807598/
|
|
198
|
+
background:
|
|
199
|
+
image: https://images.pexels.com/photos/807598/pexels-photo-807598.jpeg?cs=srgb&fm=jpg&w=4128&h=3096
|
|
200
|
+
blur: sm
|
|
201
|
+
saturate: 50
|
|
202
|
+
brightness: 50
|
|
203
|
+
opacity: 50
|
|
204
|
+
|
|
205
|
+
cardBlur: md
|
|
206
|
+
theme: dark
|
|
207
|
+
color: slate
|
|
208
|
+
|
|
209
|
+
layout:
|
|
210
|
+
Media Management:
|
|
211
|
+
style: row
|
|
212
|
+
columns: 4
|
|
213
|
+
Media Servers:
|
|
214
|
+
style: row
|
|
215
|
+
columns: 2
|
|
216
|
+
Download Clients:
|
|
217
|
+
style: row
|
|
218
|
+
columns: 2
|
|
219
|
+
`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Save Homepage configuration files
|
|
224
|
+
*/
|
|
225
|
+
export async function saveHomepageConfig(config: EasiarrConfig): Promise<{ services: string; settings: string }> {
|
|
226
|
+
const configPath = getHomepageConfigPath(config)
|
|
227
|
+
|
|
228
|
+
// Ensure directory exists
|
|
229
|
+
if (!existsSync(configPath)) {
|
|
230
|
+
await mkdir(configPath, { recursive: true })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const servicesPath = join(configPath, "services.yaml")
|
|
234
|
+
const settingsPath = join(configPath, "settings.yaml")
|
|
235
|
+
|
|
236
|
+
// Generate and save services.yaml
|
|
237
|
+
const servicesYaml = await generateServicesYaml(config)
|
|
238
|
+
await writeFile(servicesPath, servicesYaml, "utf-8")
|
|
239
|
+
|
|
240
|
+
// Generate and save settings.yaml (only if doesn't exist)
|
|
241
|
+
if (!existsSync(settingsPath)) {
|
|
242
|
+
const settingsYaml = generateSettingsYaml()
|
|
243
|
+
await writeFile(settingsPath, settingsYaml, "utf-8")
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { services: servicesPath, settings: settingsPath }
|
|
247
|
+
}
|
package/src/config/manager.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { join } from "node:path"
|
|
|
10
10
|
import type { EasiarrConfig } from "./schema"
|
|
11
11
|
import { DEFAULT_CONFIG } from "./schema"
|
|
12
12
|
import { detectTimezone, detectUid, detectGid } from "./defaults"
|
|
13
|
+
import { VersionInfo } from "../VersionInfo"
|
|
13
14
|
|
|
14
15
|
const CONFIG_DIR_NAME = ".easiarr"
|
|
15
16
|
const CONFIG_FILE_NAME = "config.json"
|
|
@@ -48,6 +49,26 @@ export async function configExists(): Promise<boolean> {
|
|
|
48
49
|
return existsSync(getConfigPath())
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Migrate config to current version
|
|
54
|
+
* Preserves user settings while adding new fields with defaults
|
|
55
|
+
*/
|
|
56
|
+
function migrateConfig(oldConfig: Partial<EasiarrConfig>): EasiarrConfig {
|
|
57
|
+
return {
|
|
58
|
+
// Start with defaults for new fields
|
|
59
|
+
...DEFAULT_CONFIG,
|
|
60
|
+
// Preserve all user settings
|
|
61
|
+
...oldConfig,
|
|
62
|
+
// Always update version to current
|
|
63
|
+
version: VersionInfo.version,
|
|
64
|
+
// Ensure required fields have values
|
|
65
|
+
umask: oldConfig.umask ?? "002",
|
|
66
|
+
apps: oldConfig.apps ?? [],
|
|
67
|
+
createdAt: oldConfig.createdAt ?? new Date().toISOString(),
|
|
68
|
+
updatedAt: new Date().toISOString(),
|
|
69
|
+
} as EasiarrConfig
|
|
70
|
+
}
|
|
71
|
+
|
|
51
72
|
export async function loadConfig(): Promise<EasiarrConfig | null> {
|
|
52
73
|
const configPath = getConfigPath()
|
|
53
74
|
|
|
@@ -57,7 +78,16 @@ export async function loadConfig(): Promise<EasiarrConfig | null> {
|
|
|
57
78
|
|
|
58
79
|
try {
|
|
59
80
|
const content = await readFile(configPath, "utf-8")
|
|
60
|
-
|
|
81
|
+
let config = JSON.parse(content) as EasiarrConfig
|
|
82
|
+
|
|
83
|
+
// Auto-migrate if version differs from current package version
|
|
84
|
+
if (config.version !== VersionInfo.version) {
|
|
85
|
+
config = migrateConfig(config)
|
|
86
|
+
// Save migrated config (creates backup first)
|
|
87
|
+
await saveConfig(config)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return config
|
|
61
91
|
} catch (error) {
|
|
62
92
|
console.error("Failed to load config:", error)
|
|
63
93
|
return null
|
package/src/config/schema.ts
CHANGED
|
@@ -120,6 +120,7 @@ export type AppId =
|
|
|
120
120
|
| "guacamole"
|
|
121
121
|
| "guacd"
|
|
122
122
|
| "ddns-updater"
|
|
123
|
+
| "easiarr-status"
|
|
123
124
|
// VPN
|
|
124
125
|
| "gluetun"
|
|
125
126
|
// Monitoring & Infra
|
|
@@ -190,6 +191,18 @@ export interface AppDefinition {
|
|
|
190
191
|
arch?: ArchCompatibility
|
|
191
192
|
/** Minimum password length requirement for user creation */
|
|
192
193
|
minPasswordLength?: number
|
|
194
|
+
/** Homepage dashboard configuration */
|
|
195
|
+
homepage?: HomepageMeta
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Homepage dashboard widget/service configuration */
|
|
199
|
+
export interface HomepageMeta {
|
|
200
|
+
/** Icon name from Dashboard Icons (e.g., "radarr.png", "mdi-web") */
|
|
201
|
+
icon?: string
|
|
202
|
+
/** Widget type for Homepage (e.g., "radarr", "sonarr", "qbittorrent") */
|
|
203
|
+
widget?: string
|
|
204
|
+
/** Custom widget fields if needed */
|
|
205
|
+
widgetFields?: Record<string, string>
|
|
193
206
|
}
|
|
194
207
|
|
|
195
208
|
export interface RootFolderMeta {
|
package/src/ui/App.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { QuickSetup } from "./screens/QuickSetup"
|
|
|
12
12
|
import { AppManager } from "./screens/AppManager"
|
|
13
13
|
import { ContainerControl } from "./screens/ContainerControl"
|
|
14
14
|
import { AdvancedSettings } from "./screens/AdvancedSettings"
|
|
15
|
+
import { checkForUpdates } from "../utils/update-checker"
|
|
16
|
+
import { UpdateNotification } from "./components/UpdateNotification"
|
|
15
17
|
|
|
16
18
|
export type Screen = "main" | "quickSetup" | "appManager" | "containerControl" | "advancedSettings"
|
|
17
19
|
|
|
@@ -40,7 +42,19 @@ export class App {
|
|
|
40
42
|
if (!this.config) {
|
|
41
43
|
this.navigateTo("quickSetup")
|
|
42
44
|
} else {
|
|
43
|
-
|
|
45
|
+
// Check for updates before showing main menu
|
|
46
|
+
const updateInfo = await checkForUpdates()
|
|
47
|
+
|
|
48
|
+
if (updateInfo.updateAvailable) {
|
|
49
|
+
// Show update notification
|
|
50
|
+
const notification = new UpdateNotification(this.renderer, updateInfo, () => {
|
|
51
|
+
// After dismissing, show main menu
|
|
52
|
+
this.navigateTo("main")
|
|
53
|
+
})
|
|
54
|
+
this.renderer.root.add(notification)
|
|
55
|
+
} else {
|
|
56
|
+
this.navigateTo("main")
|
|
57
|
+
}
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
// Handle exit
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update Notification Component
|
|
3
|
+
* Popup overlay showing new version available
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BoxRenderable, TextRenderable, CliRenderer, KeyEvent } from "@opentui/core"
|
|
7
|
+
import type { UpdateInfo } from "../../utils/update-checker"
|
|
8
|
+
|
|
9
|
+
export class UpdateNotification extends BoxRenderable {
|
|
10
|
+
private cliRenderer: CliRenderer
|
|
11
|
+
private updateInfo: UpdateInfo
|
|
12
|
+
private onDismiss: () => void
|
|
13
|
+
private keyHandler: (key: KeyEvent) => void
|
|
14
|
+
|
|
15
|
+
constructor(renderer: CliRenderer, updateInfo: UpdateInfo, onDismiss: () => void) {
|
|
16
|
+
super(renderer, {
|
|
17
|
+
id: "update-notification",
|
|
18
|
+
position: "absolute",
|
|
19
|
+
top: "50%",
|
|
20
|
+
left: "50%",
|
|
21
|
+
width: 50,
|
|
22
|
+
height: 12,
|
|
23
|
+
marginTop: -6, // Center vertically
|
|
24
|
+
marginLeft: -25, // Center horizontally
|
|
25
|
+
flexDirection: "column",
|
|
26
|
+
borderStyle: "rounded",
|
|
27
|
+
borderColor: "#50fa7b",
|
|
28
|
+
padding: 1,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
this.cliRenderer = renderer
|
|
32
|
+
this.updateInfo = updateInfo
|
|
33
|
+
this.onDismiss = onDismiss
|
|
34
|
+
|
|
35
|
+
this.buildContent()
|
|
36
|
+
|
|
37
|
+
// Key handler
|
|
38
|
+
this.keyHandler = (key: KeyEvent) => {
|
|
39
|
+
if (key.name === "return" || key.name === "escape" || key.name === "q") {
|
|
40
|
+
this.dismiss()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
renderer.keyInput.on("keypress", this.keyHandler)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private buildContent(): void {
|
|
47
|
+
// Header
|
|
48
|
+
this.add(
|
|
49
|
+
new TextRenderable(this.cliRenderer, {
|
|
50
|
+
content: "🎉 Update Available!",
|
|
51
|
+
fg: "#50fa7b",
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
// Spacer
|
|
56
|
+
this.add(new BoxRenderable(this.cliRenderer, { height: 1 }))
|
|
57
|
+
|
|
58
|
+
// Version info
|
|
59
|
+
this.add(
|
|
60
|
+
new TextRenderable(this.cliRenderer, {
|
|
61
|
+
content: `Current: v${this.updateInfo.currentVersion}`,
|
|
62
|
+
fg: "#6272a4",
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
this.add(
|
|
67
|
+
new TextRenderable(this.cliRenderer, {
|
|
68
|
+
content: `Latest: ${this.updateInfo.latestVersion}`,
|
|
69
|
+
fg: "#f1fa8c",
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// Spacer
|
|
74
|
+
this.add(new BoxRenderable(this.cliRenderer, { height: 1 }))
|
|
75
|
+
|
|
76
|
+
// Update command
|
|
77
|
+
this.add(
|
|
78
|
+
new TextRenderable(this.cliRenderer, {
|
|
79
|
+
content: "Run: bun update -g @muhammedaksam/easiarr",
|
|
80
|
+
fg: "#8be9fd",
|
|
81
|
+
})
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
// Spacer
|
|
85
|
+
this.add(new BoxRenderable(this.cliRenderer, { height: 1 }))
|
|
86
|
+
|
|
87
|
+
// Dismiss hint
|
|
88
|
+
this.add(
|
|
89
|
+
new TextRenderable(this.cliRenderer, {
|
|
90
|
+
content: "Press Enter or Esc to continue",
|
|
91
|
+
fg: "#6272a4",
|
|
92
|
+
})
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private dismiss(): void {
|
|
97
|
+
this.cliRenderer.keyInput.off("keypress", this.keyHandler)
|
|
98
|
+
this.destroy()
|
|
99
|
+
this.onDismiss()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -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
|
}
|
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
|
+
}
|