@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.6.2",
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",
@@ -228,4 +228,115 @@ export class PortainerApiClient {
228
228
  return false
229
229
  }
230
230
  }
231
+
232
+ // ==========================================
233
+ // Container Management Methods
234
+ // ==========================================
235
+
236
+ /**
237
+ * Get list of Docker endpoints
238
+ */
239
+ async getEndpoints(): Promise<PortainerEndpoint[]> {
240
+ return this.request<PortainerEndpoint[]>("/endpoints")
241
+ }
242
+
243
+ /**
244
+ * Get all containers for an endpoint
245
+ */
246
+ async getContainers(endpointId: number = 1): Promise<PortainerContainer[]> {
247
+ return this.request<PortainerContainer[]>(`/endpoints/${endpointId}/docker/containers/json?all=true`)
248
+ }
249
+
250
+ /**
251
+ * Start a container by ID
252
+ */
253
+ async startContainer(containerId: string, endpointId: number = 1): Promise<void> {
254
+ await this.request(`/endpoints/${endpointId}/docker/containers/${containerId}/start`, {
255
+ method: "POST",
256
+ })
257
+ }
258
+
259
+ /**
260
+ * Stop a container by ID
261
+ */
262
+ async stopContainer(containerId: string, endpointId: number = 1): Promise<void> {
263
+ await this.request(`/endpoints/${endpointId}/docker/containers/${containerId}/stop`, {
264
+ method: "POST",
265
+ })
266
+ }
267
+
268
+ /**
269
+ * Restart a container by ID
270
+ */
271
+ async restartContainer(containerId: string, endpointId: number = 1): Promise<void> {
272
+ await this.request(`/endpoints/${endpointId}/docker/containers/${containerId}/restart`, {
273
+ method: "POST",
274
+ })
275
+ }
276
+
277
+ /**
278
+ * Get container logs
279
+ */
280
+ async getContainerLogs(
281
+ containerId: string,
282
+ endpointId: number = 1,
283
+ options: { stdout?: boolean; stderr?: boolean; tail?: number } = {}
284
+ ): Promise<string> {
285
+ const { stdout = true, stderr = true, tail = 100 } = options
286
+ const params = new URLSearchParams({
287
+ stdout: String(stdout),
288
+ stderr: String(stderr),
289
+ tail: String(tail),
290
+ })
291
+ return this.request<string>(`/endpoints/${endpointId}/docker/containers/${containerId}/logs?${params}`)
292
+ }
293
+
294
+ /**
295
+ * Get container stats (CPU, Memory usage)
296
+ */
297
+ async getContainerStats(containerId: string, endpointId: number = 1): Promise<PortainerContainerStats> {
298
+ return this.request<PortainerContainerStats>(
299
+ `/endpoints/${endpointId}/docker/containers/${containerId}/stats?stream=false`
300
+ )
301
+ }
302
+ }
303
+
304
+ // ==========================================
305
+ // Additional Type Definitions
306
+ // ==========================================
307
+
308
+ export interface PortainerEndpoint {
309
+ Id: number
310
+ Name: string
311
+ Type: number
312
+ Status: number
313
+ URL: string
314
+ }
315
+
316
+ export interface PortainerContainer {
317
+ Id: string
318
+ Names: string[]
319
+ Image: string
320
+ State: string
321
+ Status: string
322
+ Ports: Array<{
323
+ IP?: string
324
+ PrivatePort: number
325
+ PublicPort?: number
326
+ Type: string
327
+ }>
328
+ Labels: Record<string, string>
329
+ Created: number
330
+ }
331
+
332
+ export interface PortainerContainerStats {
333
+ cpu_stats: {
334
+ cpu_usage: { total_usage: number }
335
+ system_cpu_usage: number
336
+ online_cpus: number
337
+ }
338
+ memory_stats: {
339
+ usage: number
340
+ limit: number
341
+ }
231
342
  }
@@ -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, 2010, 2020, 2030, 2040, 2045, 2050, 2060, 2070, 2080, 2090],
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, 5010, 5020, 5030, 5040, 5045, 5050, 5060, 5070, 5080, 5090],
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, 3010, 3020, 3030, 3040, 3050, 3060],
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, 7010, 7020, 7030, 7040, 7050, 7060],
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, 6010, 6020, 6030, 6040, 6045, 6050, 6060, 6070, 6080, 6090],
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": {
@@ -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
+ }
@@ -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
- return JSON.parse(content) as EasiarrConfig
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
@@ -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 {