@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,234 @@
1
+ /**
2
+ * Docker Compose Generator
3
+ * Generates docker-compose.yml from Easiarr configuration
4
+ */
5
+
6
+ import { writeFile, readFile } from "node:fs/promises"
7
+ import { existsSync } from "node:fs"
8
+ import type { EasiarrConfig, AppConfig, TraefikConfig, AppId } from "../config/schema"
9
+ import { getComposePath } from "../config/manager"
10
+ import { getApp } from "../apps/registry"
11
+ import { generateServiceYaml } from "./templates"
12
+
13
+ export interface ComposeService {
14
+ image: string
15
+ container_name: string
16
+ environment: Record<string, string | number>
17
+ volumes: string[]
18
+ ports: string[]
19
+ restart: string
20
+ depends_on?: string[]
21
+ network_mode?: string
22
+ labels?: string[]
23
+ devices?: string[]
24
+ cap_add?: string[]
25
+ }
26
+
27
+ export interface ComposeFile {
28
+ services: Record<string, ComposeService>
29
+ }
30
+
31
+ export function generateCompose(config: EasiarrConfig): string {
32
+ const services: Record<string, ComposeService> = {}
33
+
34
+ // Track ports to move to Gluetun
35
+ const gluetunPorts: string[] = []
36
+ // Track routing decisions
37
+ const routedApps = new Set<string>()
38
+
39
+ // 1. Build all services first
40
+ for (const appConfig of config.apps) {
41
+ if (!appConfig.enabled) continue
42
+
43
+ const appDef = getApp(appConfig.id)
44
+ if (!appDef) continue
45
+
46
+ const service = buildService(appDef, appConfig, config)
47
+ services[appConfig.id] = service
48
+ }
49
+
50
+ // 2. Apply VPN routing if enabled
51
+ if (config.vpn && config.vpn.mode !== "none" && services["gluetun"]) {
52
+ const vpnMode = config.vpn.mode
53
+
54
+ for (const [id, service] of Object.entries(services)) {
55
+ if (id === "gluetun") continue
56
+
57
+ const appDef = getApp(id as AppId)
58
+ if (!appDef) continue
59
+
60
+ // Determine if app should be routed
61
+ let shouldRoute = false
62
+
63
+ // Mini: Downloaders only
64
+ if (vpnMode === "mini" && appDef.category === "downloader") {
65
+ shouldRoute = true
66
+ }
67
+ // Full: Downloaders, Indexers, Requests, MediaServers, Servarr
68
+ else if (
69
+ vpnMode === "full" &&
70
+ ["downloader", "indexer", "request", "mediaserver", "servarr"].includes(appDef.category)
71
+ ) {
72
+ shouldRoute = true
73
+ }
74
+
75
+ if (shouldRoute) {
76
+ // Move ports to Gluetun
77
+ if (service.ports && service.ports.length > 0) {
78
+ gluetunPorts.push(...service.ports)
79
+ service.ports = []
80
+ }
81
+
82
+ // Set network mode
83
+ service.network_mode = "service:gluetun"
84
+ routedApps.add(id)
85
+
86
+ // Remove depends_on gluetun if it exists (circular check, though service:gluetun implies dependency)
87
+ // Actually docker-compose handles implied dependency for network_mode: service:xxx
88
+ }
89
+ }
90
+
91
+ // 3. Add ports to Gluetun
92
+ if (gluetunPorts.length > 0) {
93
+ services["gluetun"].ports = [...new Set([...(services["gluetun"].ports || []), ...gluetunPorts])]
94
+ }
95
+ }
96
+
97
+ return formatComposeYaml({ services })
98
+ }
99
+
100
+ function buildService(appDef: ReturnType<typeof getApp>, appConfig: AppConfig, config: EasiarrConfig): ComposeService {
101
+ if (!appDef) throw new Error("App definition not found")
102
+
103
+ const port = appConfig.port ?? appDef.defaultPort
104
+ // Use ${ROOT_DIR} for volumes
105
+ const volumes = [...appDef.volumes("${ROOT_DIR}"), ...(appConfig.customVolumes ?? [])]
106
+
107
+ // Build environment
108
+ const environment: Record<string, string | number> = {
109
+ TZ: "${TIMEZONE}",
110
+ }
111
+
112
+ // Add PUID/PGID (Use globals)
113
+ if (appDef.puid > 0 || appDef.pgid > 0 || ["jellyfin", "tautulli"].includes(appDef.id)) {
114
+ environment.PUID = "${PUID}"
115
+ environment.PGID = "${PGID}"
116
+ environment.UMASK = "${UMASK}"
117
+ }
118
+
119
+ // Add app-specific environment
120
+ if (appDef.environment) {
121
+ Object.assign(environment, appDef.environment)
122
+ }
123
+
124
+ // Add custom environment from config
125
+ if (appConfig.customEnv) {
126
+ Object.assign(environment, appConfig.customEnv)
127
+ }
128
+
129
+ const service: ComposeService = {
130
+ image: appDef.image,
131
+ container_name: appDef.id,
132
+ environment,
133
+ volumes,
134
+ ports: appDef.id === "plex" ? [] : [`"${port}:${appDef.defaultPort}"`],
135
+ restart: "unless-stopped",
136
+ }
137
+
138
+ // Add devices/caps
139
+ if (appDef.devices) service.devices = [...appDef.devices]
140
+ if (appDef.cap_add) service.cap_add = [...appDef.cap_add]
141
+
142
+ // Plex uses network_mode: host
143
+ if (appDef.id === "plex") {
144
+ service.network_mode = "host"
145
+ }
146
+
147
+ // Add dependencies
148
+ if (appDef.dependsOn && appDef.dependsOn.length > 0) {
149
+ const enabledDeps = appDef.dependsOn.filter((dep) => config.apps.some((a) => a.id === dep && a.enabled))
150
+ if (enabledDeps.length > 0) {
151
+ service.depends_on = enabledDeps
152
+ }
153
+ }
154
+
155
+ // Add Traefik labels if enabled (skip for traefik itself and plex with host networking)
156
+ if (config.traefik?.enabled && appDef.id !== "traefik" && appDef.id !== "plex") {
157
+ service.labels = generateTraefikLabels(appDef.id, appDef.defaultPort, config.traefik)
158
+ }
159
+
160
+ return service
161
+ }
162
+
163
+ function generateTraefikLabels(serviceName: string, port: number, traefik: TraefikConfig): string[] {
164
+ const labels: string[] = [
165
+ "traefik.enable=true",
166
+ // Router
167
+ `traefik.http.routers.${serviceName}.service=${serviceName}`,
168
+ `traefik.http.routers.${serviceName}.rule=Host(\`${serviceName}.${traefik.domain}\`)`,
169
+ `traefik.http.routers.${serviceName}.entrypoints=${traefik.entrypoint}`,
170
+ ]
171
+
172
+ // Add middlewares if configured
173
+ if (traefik.middlewares.length > 0) {
174
+ labels.push(`traefik.http.routers.${serviceName}.middlewares=${traefik.middlewares.join(",")}`)
175
+ }
176
+
177
+ // Service/Load balancer
178
+ labels.push(
179
+ `traefik.http.services.${serviceName}.loadbalancer.server.scheme=http`,
180
+ `traefik.http.services.${serviceName}.loadbalancer.server.port=${port}`
181
+ )
182
+
183
+ return labels
184
+ }
185
+
186
+ function formatComposeYaml(compose: ComposeFile): string {
187
+ let yaml = "---\nservices:\n"
188
+
189
+ for (const [name, service] of Object.entries(compose.services)) {
190
+ yaml += generateServiceYaml(name, service)
191
+ }
192
+
193
+ return yaml
194
+ }
195
+
196
+ export async function saveCompose(config: EasiarrConfig): Promise<string> {
197
+ const yaml = generateCompose(config)
198
+ const path = getComposePath()
199
+ await writeFile(path, yaml, "utf-8")
200
+
201
+ // Update .env
202
+ await updateEnvFile(config)
203
+
204
+ return path
205
+ }
206
+
207
+ async function updateEnvFile(config: EasiarrConfig) {
208
+ const envPath = getComposePath().replace("docker-compose.yml", ".env")
209
+ let envContent = ""
210
+
211
+ // Read existing .env if present to preserve secrets
212
+ const currentEnv: Record<string, string> = {}
213
+ if (existsSync(envPath)) {
214
+ const content = await readFile(envPath, "utf-8")
215
+ content.split("\n").forEach((line) => {
216
+ const [key, ...val] = line.split("=")
217
+ if (key && val) currentEnv[key.trim()] = val.join("=").trim()
218
+ })
219
+ }
220
+
221
+ // Update/Set globals
222
+ currentEnv["ROOT_DIR"] = config.rootDir
223
+ currentEnv["TIMEZONE"] = config.timezone
224
+ currentEnv["PUID"] = config.uid.toString()
225
+ currentEnv["PGID"] = config.gid.toString()
226
+ currentEnv["UMASK"] = config.umask
227
+
228
+ // Reconstruct .env content
229
+ envContent = Object.entries(currentEnv)
230
+ .map(([k, v]) => `${k}=${v}`)
231
+ .join("\n")
232
+
233
+ await writeFile(envPath, envContent, "utf-8")
234
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./generator"
2
+ export * from "./templates"
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Docker Compose YAML Templates
3
+ * Generates properly formatted YAML for services
4
+ */
5
+
6
+ import type { ComposeService } from "./generator"
7
+
8
+ export function generateServiceYaml(name: string, service: ComposeService): string {
9
+ let yaml = ` ${name}:\n`
10
+ yaml += ` image: ${service.image}\n`
11
+ yaml += ` container_name: ${service.container_name}\n`
12
+
13
+ // Network mode (for Plex)
14
+ if (service.network_mode) {
15
+ yaml += ` network_mode: ${service.network_mode}\n`
16
+ }
17
+
18
+ // Dependencies
19
+ if (service.depends_on && service.depends_on.length > 0) {
20
+ yaml += ` depends_on:\n`
21
+ for (const dep of service.depends_on) {
22
+ yaml += ` - ${dep}\n`
23
+ }
24
+ }
25
+
26
+ // Environment
27
+ if (Object.keys(service.environment).length > 0) {
28
+ yaml += ` environment:\n`
29
+ for (const [key, value] of Object.entries(service.environment)) {
30
+ yaml += ` - ${key}=${value}\n`
31
+ }
32
+ }
33
+
34
+ // Volumes
35
+ if (service.volumes.length > 0) {
36
+ yaml += ` volumes:\n`
37
+ for (const volume of service.volumes) {
38
+ yaml += ` - ${volume}\n`
39
+ }
40
+ }
41
+
42
+ // Ports (skip for network_mode: host)
43
+ if (service.ports.length > 0 && !service.network_mode) {
44
+ yaml += ` ports:\n`
45
+ for (const port of service.ports) {
46
+ yaml += ` - ${port}\n`
47
+ }
48
+ }
49
+
50
+ // Labels (for Traefik etc.)
51
+ if (service.labels && service.labels.length > 0) {
52
+ yaml += ` labels:\n`
53
+ for (const label of service.labels) {
54
+ yaml += ` - ${label}\n`
55
+ }
56
+ }
57
+
58
+ yaml += ` restart: ${service.restart}\n\n`
59
+
60
+ return yaml
61
+ }
62
+
63
+ export function generateNetworkYaml(name: string, driver: string): string {
64
+ return `networks:
65
+ ${name}:
66
+ driver: ${driver}
67
+ `
68
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Default Value Detection
3
+ * Auto-detect timezone and UID/GID from system
4
+ */
5
+
6
+ import { readlinkSync, existsSync } from "node:fs"
7
+
8
+ export function detectTimezone(): string {
9
+ const tzPath = "/etc/localtime"
10
+
11
+ if (existsSync(tzPath)) {
12
+ try {
13
+ const link = readlinkSync(tzPath)
14
+ const parts = link.split("zoneinfo/")
15
+ if (parts.length > 1) {
16
+ return parts[1]
17
+ }
18
+ } catch {
19
+ // Fallback if not a symlink
20
+ }
21
+ }
22
+
23
+ // Try TZ environment variable
24
+ if (process.env.TZ) {
25
+ return process.env.TZ
26
+ }
27
+
28
+ return "UTC"
29
+ }
30
+
31
+ export function detectUid(): number {
32
+ return process.getuid?.() ?? 1000
33
+ }
34
+
35
+ export function detectGid(): number {
36
+ return process.getgid?.() ?? 1000
37
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./schema"
2
+ export * from "./manager"
3
+ export * from "./defaults"
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Configuration Manager
3
+ * Handles reading and writing config to ~/.easiarr/
4
+ */
5
+
6
+ import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises"
7
+ import { existsSync } from "node:fs"
8
+ import { homedir } from "node:os"
9
+ import { join } from "node:path"
10
+ import type { EasiarrConfig } from "./schema"
11
+ import { DEFAULT_CONFIG } from "./schema"
12
+ import { detectTimezone, detectUid, detectGid } from "./defaults"
13
+
14
+ const CONFIG_DIR_NAME = ".easiarr"
15
+ const CONFIG_FILE_NAME = "config.json"
16
+ const BACKUP_DIR_NAME = "backups"
17
+
18
+ export function getConfigDir(): string {
19
+ return join(homedir(), CONFIG_DIR_NAME)
20
+ }
21
+
22
+ export function getConfigPath(): string {
23
+ return join(getConfigDir(), CONFIG_FILE_NAME)
24
+ }
25
+
26
+ export function getBackupDir(): string {
27
+ return join(getConfigDir(), BACKUP_DIR_NAME)
28
+ }
29
+
30
+ export function getComposePath(): string {
31
+ return join(getConfigDir(), "docker-compose.yml")
32
+ }
33
+
34
+ export async function ensureConfigDir(): Promise<void> {
35
+ const configDir = getConfigDir()
36
+ const backupDir = getBackupDir()
37
+
38
+ if (!existsSync(configDir)) {
39
+ await mkdir(configDir, { recursive: true })
40
+ }
41
+
42
+ if (!existsSync(backupDir)) {
43
+ await mkdir(backupDir, { recursive: true })
44
+ }
45
+ }
46
+
47
+ export async function configExists(): Promise<boolean> {
48
+ return existsSync(getConfigPath())
49
+ }
50
+
51
+ export async function loadConfig(): Promise<EasiarrConfig | null> {
52
+ const configPath = getConfigPath()
53
+
54
+ if (!existsSync(configPath)) {
55
+ return null
56
+ }
57
+
58
+ try {
59
+ const content = await readFile(configPath, "utf-8")
60
+ return JSON.parse(content) as EasiarrConfig
61
+ } catch (error) {
62
+ console.error("Failed to load config:", error)
63
+ return null
64
+ }
65
+ }
66
+
67
+ export async function saveConfig(config: EasiarrConfig): Promise<void> {
68
+ await ensureConfigDir()
69
+
70
+ const configPath = getConfigPath()
71
+
72
+ // Create backup if config already exists
73
+ if (existsSync(configPath)) {
74
+ await backupConfig()
75
+ }
76
+
77
+ // Update timestamp
78
+ config.updatedAt = new Date().toISOString()
79
+
80
+ await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8")
81
+ }
82
+
83
+ export async function backupConfig(): Promise<void> {
84
+ const configPath = getConfigPath()
85
+
86
+ if (!existsSync(configPath)) {
87
+ return
88
+ }
89
+
90
+ const backupDir = getBackupDir()
91
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
92
+ const backupPath = join(backupDir, `config-${timestamp}.json`)
93
+
94
+ await copyFile(configPath, backupPath)
95
+ }
96
+
97
+ export function createDefaultConfig(rootDir: string): EasiarrConfig {
98
+ const now = new Date().toISOString()
99
+
100
+ return {
101
+ ...DEFAULT_CONFIG,
102
+ rootDir,
103
+ timezone: detectTimezone(),
104
+ uid: detectUid(),
105
+ gid: detectGid(),
106
+ createdAt: now,
107
+ updatedAt: now,
108
+ }
109
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Easiarr Configuration Schema
3
+ * TypeScript interfaces for configuration management
4
+ */
5
+
6
+ import { VersionInfo } from "../VersionInfo"
7
+
8
+ export interface EasiarrConfig {
9
+ version: string
10
+ rootDir: string
11
+ timezone: string
12
+ uid: number
13
+ gid: number
14
+ umask: string
15
+ apps: AppConfig[]
16
+ network?: NetworkConfig
17
+ traefik?: TraefikConfig
18
+ vpn?: VpnConfig
19
+ createdAt: string
20
+ updatedAt: string
21
+ }
22
+
23
+ export type VpnMode = "full" | "mini" | "none"
24
+
25
+ export interface VpnConfig {
26
+ mode: VpnMode
27
+ provider?: string // For future use (e.g. custom, airvpn, protonvpn)
28
+ }
29
+
30
+ export interface TraefikConfig {
31
+ enabled: boolean
32
+ domain: string
33
+ entrypoint: string
34
+ middlewares: string[]
35
+ }
36
+
37
+ export interface AppConfig {
38
+ id: AppId
39
+ enabled: boolean
40
+ port?: number
41
+ customEnv?: Record<string, string>
42
+ customVolumes?: string[]
43
+ labels?: string[]
44
+ devices?: string[]
45
+ cap_add?: string[]
46
+ }
47
+
48
+ export interface NetworkConfig {
49
+ name: string
50
+ driver: "bridge" | "host" | "none"
51
+ }
52
+
53
+ export type AppId =
54
+ // Media Management (Servarr)
55
+ | "radarr"
56
+ | "sonarr"
57
+ | "lidarr"
58
+ | "readarr"
59
+ | "bazarr"
60
+ | "mylar3"
61
+ | "whisparr"
62
+ | "audiobookshelf"
63
+ // Indexers
64
+ | "prowlarr"
65
+ | "jackett"
66
+ | "flaresolverr"
67
+ // Download Clients
68
+ | "qbittorrent"
69
+ | "sabnzbd"
70
+ // Media Servers
71
+ | "plex"
72
+ | "jellyfin"
73
+ | "tautulli"
74
+ | "tdarr"
75
+ // Request Management
76
+ | "overseerr"
77
+ | "jellyseerr"
78
+ // Dashboards
79
+ | "homarr"
80
+ | "heimdall"
81
+ | "homepage"
82
+ // Utilities
83
+ | "huntarr"
84
+ | "unpackerr"
85
+ | "filebot"
86
+ | "chromium"
87
+ | "guacamole"
88
+ | "guacd"
89
+ | "ddns-updater"
90
+ // VPN
91
+ | "gluetun"
92
+ // Monitoring & Infra
93
+ | "portainer"
94
+ | "dozzle"
95
+ | "uptime-kuma"
96
+ // Monitoring
97
+ | "grafana"
98
+ | "prometheus"
99
+ // Reverse Proxy
100
+ | "traefik"
101
+ | "traefik-certs-dumper"
102
+ | "crowdsec"
103
+ // Network/VPN
104
+ | "headscale"
105
+ | "headplane"
106
+ | "tailscale"
107
+ // Authentication
108
+ | "authentik"
109
+ | "authentik-worker"
110
+ // Database
111
+ | "postgresql"
112
+ | "valkey"
113
+
114
+ export type AppCategory =
115
+ | "servarr"
116
+ | "indexer"
117
+ | "downloader"
118
+ | "mediaserver"
119
+ | "request"
120
+ | "dashboard"
121
+ | "utility"
122
+ | "vpn"
123
+ | "monitoring"
124
+ | "infrastructure"
125
+
126
+ export interface AppDefinition {
127
+ id: AppId
128
+ name: string
129
+ description: string
130
+ category: AppCategory
131
+ defaultPort: number
132
+ image: string
133
+ puid: number
134
+ pgid: number
135
+ volumes: (rootDir: string) => string[]
136
+ environment?: Record<string, string>
137
+ dependsOn?: AppId[]
138
+ trashGuide?: string
139
+ secrets?: AppSecret[]
140
+ devices?: string[]
141
+ cap_add?: string[]
142
+ apiKeyMeta?: ApiKeyMeta
143
+ rootFolder?: RootFolderMeta
144
+ }
145
+
146
+ export interface RootFolderMeta {
147
+ path: string // e.g. "/data/media/movies"
148
+ apiVersion: "v1" | "v3"
149
+ }
150
+
151
+ export interface ApiKeyMeta {
152
+ configFile: string // Relative to config volume root
153
+ parser: ApiKeyParserType
154
+ selector: string // Regex group 1, or XML tag, or INI key, or JSON path
155
+ description?: string
156
+ transform?: (value: string) => string
157
+ }
158
+
159
+ export type ApiKeyParserType = "xml" | "ini" | "json" | "regex"
160
+
161
+ export interface AppSecret {
162
+ name: string
163
+ description: string
164
+ required: boolean
165
+ default?: string
166
+ generate?: boolean // Suggest auto-generation
167
+ mask?: boolean // Mask input
168
+ }
169
+
170
+ export const APP_CATEGORIES: Record<AppCategory, string> = {
171
+ servarr: "Media Management",
172
+ indexer: "Indexers",
173
+ downloader: "Download Clients",
174
+ mediaserver: "Media Servers",
175
+ request: "Request Management",
176
+ dashboard: "Dashboards",
177
+ utility: "Utilities",
178
+ vpn: "VPN",
179
+ monitoring: "Monitoring",
180
+ infrastructure: "Infrastructure",
181
+ }
182
+
183
+ export const DEFAULT_CONFIG: Omit<EasiarrConfig, "createdAt" | "updatedAt"> = {
184
+ version: VersionInfo.version,
185
+ rootDir: "",
186
+ timezone: "",
187
+ uid: 1000,
188
+ gid: 1000,
189
+ umask: "002",
190
+ apps: [],
191
+ }