@muhammedaksam/easiarr 1.1.8 → 1.2.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/README.md +9 -2
- package/package.json +4 -4
- package/src/api/profilarr-api.ts +284 -0
- package/src/apps/registry.ts +92 -0
- package/src/compose/caddy-config.ts +129 -0
- package/src/compose/generator.ts +10 -1
- package/src/config/recyclarr-config.ts +179 -0
- package/src/config/schema.ts +19 -0
- package/src/docker/client.ts +16 -0
- package/src/structure/manager.ts +41 -1
- package/src/ui/screens/FullAutoSetup.ts +123 -1
- package/src/ui/screens/LogsViewer.ts +468 -0
- package/src/ui/screens/MainMenu.ts +14 -0
- package/src/ui/screens/QuickSetup.ts +52 -3
- package/src/ui/screens/RecyclarrSetup.ts +418 -0
- package/src/ui/screens/SettingsScreen.ts +35 -1
- package/src/utils/logs.ts +118 -0
- package/src/utils/unraid.ts +101 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caddy Configuration Generator
|
|
3
|
+
* Generates Caddyfile for automatic HTTPS reverse proxy
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
7
|
+
import { existsSync } from "node:fs"
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
import type { EasiarrConfig } from "../config/schema"
|
|
10
|
+
import { getApp } from "../apps/registry"
|
|
11
|
+
import { debugLog } from "../utils/debug"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate Caddyfile content for enabled apps
|
|
15
|
+
*/
|
|
16
|
+
export function generateCaddyfile(config: EasiarrConfig): string {
|
|
17
|
+
const domain = config.caddy?.domain || "localhost"
|
|
18
|
+
const email = config.caddy?.email
|
|
19
|
+
|
|
20
|
+
let caddyfile = `# Caddyfile
|
|
21
|
+
# Generated by easiarr - https://github.com/muhammedaksam/easiarr
|
|
22
|
+
#
|
|
23
|
+
# Caddy provides automatic HTTPS via Let's Encrypt.
|
|
24
|
+
# For local dev, use 'localhost' or ':80' as domain.
|
|
25
|
+
#
|
|
26
|
+
# To manually reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile
|
|
27
|
+
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
// Global options block (if email is set for ACME)
|
|
31
|
+
if (email) {
|
|
32
|
+
caddyfile += `{
|
|
33
|
+
email ${email}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Get enabled apps that should be proxied
|
|
40
|
+
const proxyApps = config.apps.filter((app) => {
|
|
41
|
+
if (!app.enabled) return false
|
|
42
|
+
// Skip caddy itself and apps without ports
|
|
43
|
+
if (app.id === "caddy" || app.id === "cloudflared") return false
|
|
44
|
+
const def = getApp(app.id)
|
|
45
|
+
if (!def || def.defaultPort === 0) return false
|
|
46
|
+
return true
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Generate reverse proxy blocks for each app
|
|
50
|
+
for (const app of proxyApps) {
|
|
51
|
+
const def = getApp(app.id)
|
|
52
|
+
if (!def) continue
|
|
53
|
+
|
|
54
|
+
const port = app.port ?? def.defaultPort
|
|
55
|
+
const subdomain = app.id
|
|
56
|
+
const internalPort = def.internalPort ?? port
|
|
57
|
+
|
|
58
|
+
// Format: subdomain.domain.com
|
|
59
|
+
const host = domain === "localhost" ? `${subdomain}.localhost` : `${subdomain}.${domain}`
|
|
60
|
+
|
|
61
|
+
caddyfile += `# ${def.name}
|
|
62
|
+
${host} {
|
|
63
|
+
reverse_proxy ${app.id}:${internalPort}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return caddyfile
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Save Caddyfile to the config directory
|
|
74
|
+
*/
|
|
75
|
+
export async function saveCaddyConfig(config: EasiarrConfig): Promise<string> {
|
|
76
|
+
// Check if Caddy is enabled
|
|
77
|
+
const caddyApp = config.apps.find((a) => a.id === "caddy" && a.enabled)
|
|
78
|
+
if (!caddyApp) {
|
|
79
|
+
debugLog("Caddy", "Caddy not enabled, skipping config generation")
|
|
80
|
+
return ""
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const caddyDir = join(config.rootDir, "config", "caddy")
|
|
84
|
+
const dataDir = join(caddyDir, "data")
|
|
85
|
+
const configDir = join(caddyDir, "config")
|
|
86
|
+
|
|
87
|
+
debugLog("Caddy", `Generating Caddyfile in ${caddyDir}`)
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Create directories if they don't exist
|
|
91
|
+
if (!existsSync(caddyDir)) {
|
|
92
|
+
await mkdir(caddyDir, { recursive: true })
|
|
93
|
+
}
|
|
94
|
+
if (!existsSync(dataDir)) {
|
|
95
|
+
await mkdir(dataDir, { recursive: true })
|
|
96
|
+
}
|
|
97
|
+
if (!existsSync(configDir)) {
|
|
98
|
+
await mkdir(configDir, { recursive: true })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Generate Caddyfile
|
|
102
|
+
const caddyfileContent = generateCaddyfile(config)
|
|
103
|
+
const caddyfilePath = join(caddyDir, "Caddyfile")
|
|
104
|
+
|
|
105
|
+
// Always overwrite Caddyfile to reflect current app config
|
|
106
|
+
await writeFile(caddyfilePath, caddyfileContent, "utf-8")
|
|
107
|
+
debugLog("Caddy", `Saved Caddyfile to ${caddyfilePath}`)
|
|
108
|
+
|
|
109
|
+
return caddyfilePath
|
|
110
|
+
} catch (error) {
|
|
111
|
+
const err = error as NodeJS.ErrnoException
|
|
112
|
+
if (err.code === "EACCES") {
|
|
113
|
+
console.warn(
|
|
114
|
+
`[WARN] Cannot write Caddy config files (permission denied). ` +
|
|
115
|
+
`Fix with: sudo chown -R $(id -u):$(id -g) ${caddyDir}`
|
|
116
|
+
)
|
|
117
|
+
} else {
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
return ""
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get the path to the Caddyfile
|
|
126
|
+
*/
|
|
127
|
+
export function getCaddyfilePath(config: EasiarrConfig): string {
|
|
128
|
+
return join(config.rootDir, "config", "caddy", "Caddyfile")
|
|
129
|
+
}
|
package/src/compose/generator.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { getApp } from "../apps/registry"
|
|
|
10
10
|
import { generateServiceYaml } from "./templates"
|
|
11
11
|
import { updateEnv, getLocalIp } from "../utils/env"
|
|
12
12
|
import { saveTraefikConfig } from "./traefik-config"
|
|
13
|
+
import { saveCaddyConfig } from "./caddy-config"
|
|
13
14
|
import { debugLog } from "../utils/debug"
|
|
14
15
|
|
|
15
16
|
export interface ComposeService {
|
|
@@ -111,6 +112,11 @@ function buildService(appDef: ReturnType<typeof getApp>, appConfig: AppConfig, c
|
|
|
111
112
|
// Use ${ROOT_DIR} for volumes
|
|
112
113
|
const volumes = [...appDef.volumes("${ROOT_DIR}"), ...(appConfig.customVolumes ?? [])]
|
|
113
114
|
|
|
115
|
+
// Add log volume mount if logMount is enabled and app has logVolume defined
|
|
116
|
+
if (config.logMount && appDef.logVolume) {
|
|
117
|
+
volumes.push(`\${ROOT_DIR}/logs/${appDef.id}:${appDef.logVolume}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
114
120
|
// Build environment
|
|
115
121
|
const environment: Record<string, string | number> = {
|
|
116
122
|
TZ: "${TIMEZONE}",
|
|
@@ -181,7 +187,7 @@ function buildService(appDef: ReturnType<typeof getApp>, appConfig: AppConfig, c
|
|
|
181
187
|
}
|
|
182
188
|
}
|
|
183
189
|
|
|
184
|
-
if (config.traefik?.enabled && appDef.id !== "plex" && appDef.id !== "cloudflared") {
|
|
190
|
+
if (config.traefik?.enabled && appDef.id !== "plex" && appDef.id !== "cloudflared" && appDef.defaultPort !== 0) {
|
|
185
191
|
if (appDef.id === "traefik") {
|
|
186
192
|
// Special labels for Traefik dashboard (accessible via traefik.domain on port 8080)
|
|
187
193
|
service.labels = generateTraefikLabels("traefik", 8080, config.traefik)
|
|
@@ -237,6 +243,9 @@ export async function saveCompose(config: EasiarrConfig): Promise<string> {
|
|
|
237
243
|
// Generate Traefik config files if Traefik is enabled
|
|
238
244
|
await saveTraefikConfig(config)
|
|
239
245
|
|
|
246
|
+
// Generate Caddy config files if Caddy is enabled
|
|
247
|
+
await saveCaddyConfig(config)
|
|
248
|
+
|
|
240
249
|
return path
|
|
241
250
|
}
|
|
242
251
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recyclarr Configuration Generator
|
|
3
|
+
* Generates recyclarr.yml for automatic TRaSH Guides profile sync
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import YAML from "yaml"
|
|
9
|
+
import type { EasiarrConfig, AppId } from "./schema"
|
|
10
|
+
import { readEnv } from "../utils/env"
|
|
11
|
+
import { debugLog } from "../utils/debug"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get API key for an app from the .env file
|
|
15
|
+
*/
|
|
16
|
+
async function getAppApiKey(appId: AppId): Promise<string | null> {
|
|
17
|
+
const env = await readEnv()
|
|
18
|
+
const envKey = `API_KEY_${appId.toUpperCase()}`
|
|
19
|
+
return env[envKey] ?? null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// TRaSH Guide Custom Format IDs for common unwanted content
|
|
23
|
+
const UNWANTED_CFS = {
|
|
24
|
+
radarr: [
|
|
25
|
+
"ed38b889b31be83fda192888e2286d83", // BR-DISK
|
|
26
|
+
"90a6f9a284dff5103f6346090e6280c8", // LQ
|
|
27
|
+
"e204b80c87be9497a8a6eaff48f72905", // LQ (Release Title)
|
|
28
|
+
"dc98083864ea246d05a42df0d05f81cc", // x265 (HD)
|
|
29
|
+
"0a3f082873eb454bde444150b70253cc", // Extras
|
|
30
|
+
],
|
|
31
|
+
sonarr: [
|
|
32
|
+
"85c61753df5da1fb2aab6f2a47426b09", // BR-DISK
|
|
33
|
+
"9c11cd3f07101cdba90a2d81cf0e56b4", // LQ
|
|
34
|
+
"e2315f990da2e2cbfc9fa5b7a6fcfe48", // LQ (Release Title)
|
|
35
|
+
"47435ece6b99a0b477caf360e79ba0bb", // x265 (HD)
|
|
36
|
+
"fbcb31d8dabd2a319072b84fc0b7249c", // Extras
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// TRaSH Guide Quality Definition types
|
|
41
|
+
const QUALITY_DEFINITIONS = {
|
|
42
|
+
radarr: "movie",
|
|
43
|
+
sonarr: "series",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Default quality profiles to assign scores to
|
|
47
|
+
const DEFAULT_PROFILES = {
|
|
48
|
+
radarr: ["HD", "HD-1080p", "Ultra-HD", "Any"],
|
|
49
|
+
sonarr: ["WEB-1080p", "WEB-DL (1080p)", "HD-1080p", "Any"],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface RecyclarrInstance {
|
|
53
|
+
base_url: string
|
|
54
|
+
api_key: string
|
|
55
|
+
quality_definition?: {
|
|
56
|
+
type: string
|
|
57
|
+
preferred_ratio?: number
|
|
58
|
+
}
|
|
59
|
+
custom_formats?: Array<{
|
|
60
|
+
trash_ids: string[]
|
|
61
|
+
quality_profiles: Array<{
|
|
62
|
+
name: string
|
|
63
|
+
}>
|
|
64
|
+
}>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RecyclarrConfig {
|
|
68
|
+
radarr?: Record<string, RecyclarrInstance>
|
|
69
|
+
sonarr?: Record<string, RecyclarrInstance>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Generate recyclarr.yml configuration for enabled *arr apps
|
|
74
|
+
*/
|
|
75
|
+
export async function generateRecyclarrConfig(config: EasiarrConfig): Promise<string> {
|
|
76
|
+
debugLog("Recyclarr", "Generating recyclarr.yml configuration")
|
|
77
|
+
|
|
78
|
+
const recyclarrConfig: RecyclarrConfig = {}
|
|
79
|
+
|
|
80
|
+
// Check for enabled Radarr
|
|
81
|
+
const radarrApp = config.apps.find((a) => a.id === "radarr" && a.enabled)
|
|
82
|
+
if (radarrApp) {
|
|
83
|
+
const apiKey = await getAppApiKey("radarr")
|
|
84
|
+
if (apiKey) {
|
|
85
|
+
const port = radarrApp.port ?? 7878
|
|
86
|
+
recyclarrConfig.radarr = {
|
|
87
|
+
radarr_main: {
|
|
88
|
+
base_url: `http://radarr:${port}`,
|
|
89
|
+
api_key: apiKey,
|
|
90
|
+
quality_definition: {
|
|
91
|
+
type: QUALITY_DEFINITIONS.radarr,
|
|
92
|
+
preferred_ratio: 0.5,
|
|
93
|
+
},
|
|
94
|
+
custom_formats: [
|
|
95
|
+
{
|
|
96
|
+
trash_ids: UNWANTED_CFS.radarr,
|
|
97
|
+
quality_profiles: DEFAULT_PROFILES.radarr.map((name) => ({ name })),
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
debugLog("Recyclarr", "Added Radarr configuration")
|
|
103
|
+
} else {
|
|
104
|
+
debugLog("Recyclarr", "Radarr enabled but no API key found - skipping")
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check for enabled Sonarr
|
|
109
|
+
const sonarrApp = config.apps.find((a) => a.id === "sonarr" && a.enabled)
|
|
110
|
+
if (sonarrApp) {
|
|
111
|
+
const apiKey = await getAppApiKey("sonarr")
|
|
112
|
+
if (apiKey) {
|
|
113
|
+
const port = sonarrApp.port ?? 8989
|
|
114
|
+
recyclarrConfig.sonarr = {
|
|
115
|
+
sonarr_main: {
|
|
116
|
+
base_url: `http://sonarr:${port}`,
|
|
117
|
+
api_key: apiKey,
|
|
118
|
+
quality_definition: {
|
|
119
|
+
type: QUALITY_DEFINITIONS.sonarr,
|
|
120
|
+
},
|
|
121
|
+
custom_formats: [
|
|
122
|
+
{
|
|
123
|
+
trash_ids: UNWANTED_CFS.sonarr,
|
|
124
|
+
quality_profiles: DEFAULT_PROFILES.sonarr.map((name) => ({ name })),
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
debugLog("Recyclarr", "Added Sonarr configuration")
|
|
130
|
+
} else {
|
|
131
|
+
debugLog("Recyclarr", "Sonarr enabled but no API key found - skipping")
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Generate YAML
|
|
136
|
+
const yamlContent = YAML.stringify(recyclarrConfig, {
|
|
137
|
+
indent: 2,
|
|
138
|
+
lineWidth: 0,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Add header comment
|
|
142
|
+
const header = `# Recyclarr Configuration
|
|
143
|
+
# Generated by easiarr - https://github.com/muhammedaksam/easiarr
|
|
144
|
+
#
|
|
145
|
+
# This configuration syncs TRaSH Guides profiles to your *arr apps.
|
|
146
|
+
# Recyclarr runs daily via cron to keep profiles up to date.
|
|
147
|
+
#
|
|
148
|
+
# For more options, see: https://recyclarr.dev/reference/configuration/
|
|
149
|
+
#
|
|
150
|
+
# To manually trigger sync: docker compose run --rm recyclarr sync
|
|
151
|
+
#
|
|
152
|
+
|
|
153
|
+
`
|
|
154
|
+
|
|
155
|
+
return header + yamlContent
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Save recyclarr.yml to the config directory
|
|
160
|
+
*/
|
|
161
|
+
export async function saveRecyclarrConfig(config: EasiarrConfig): Promise<string> {
|
|
162
|
+
const recyclarrDir = join(config.rootDir, "config", "recyclarr")
|
|
163
|
+
await mkdir(recyclarrDir, { recursive: true })
|
|
164
|
+
|
|
165
|
+
const configContent = await generateRecyclarrConfig(config)
|
|
166
|
+
const configPath = join(recyclarrDir, "recyclarr.yml")
|
|
167
|
+
|
|
168
|
+
await writeFile(configPath, configContent, "utf-8")
|
|
169
|
+
debugLog("Recyclarr", `Saved recyclarr.yml to ${configPath}`)
|
|
170
|
+
|
|
171
|
+
return configPath
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get the path to the recyclarr config file
|
|
176
|
+
*/
|
|
177
|
+
export function getRecyclarrConfigPath(config: EasiarrConfig): string {
|
|
178
|
+
return join(config.rootDir, "config", "recyclarr", "recyclarr.yml")
|
|
179
|
+
}
|
package/src/config/schema.ts
CHANGED
|
@@ -14,13 +14,27 @@ export interface EasiarrConfig {
|
|
|
14
14
|
umask: string
|
|
15
15
|
apps: AppConfig[]
|
|
16
16
|
network?: NetworkConfig
|
|
17
|
+
/** Which reverse proxy to use: traefik, caddy, or none */
|
|
18
|
+
reverseProxy?: "traefik" | "caddy" | "none"
|
|
17
19
|
traefik?: TraefikConfig
|
|
20
|
+
caddy?: CaddyConfig
|
|
18
21
|
vpn?: VpnConfig
|
|
19
22
|
monitor?: MonitorConfig
|
|
23
|
+
/** Bind-mount container logs to host for external log aggregation */
|
|
24
|
+
logMount?: boolean
|
|
20
25
|
createdAt: string
|
|
21
26
|
updatedAt: string
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
export type ReverseProxyType = "traefik" | "caddy" | "none"
|
|
30
|
+
|
|
31
|
+
export interface CaddyConfig {
|
|
32
|
+
enabled: boolean
|
|
33
|
+
domain: string
|
|
34
|
+
/** Email for ACME/Let's Encrypt */
|
|
35
|
+
email?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
export type VpnMode = "full" | "mini" | "none"
|
|
25
39
|
|
|
26
40
|
export interface VpnConfig {
|
|
@@ -129,6 +143,8 @@ export type AppId =
|
|
|
129
143
|
| "guacd"
|
|
130
144
|
| "ddns-updater"
|
|
131
145
|
| "easiarr"
|
|
146
|
+
| "recyclarr"
|
|
147
|
+
| "profilarr"
|
|
132
148
|
// VPN
|
|
133
149
|
| "gluetun"
|
|
134
150
|
// Monitoring & Infra
|
|
@@ -141,6 +157,7 @@ export type AppId =
|
|
|
141
157
|
// Reverse Proxy
|
|
142
158
|
| "traefik"
|
|
143
159
|
| "traefik-certs-dumper"
|
|
160
|
+
| "caddy"
|
|
144
161
|
| "cloudflared"
|
|
145
162
|
| "crowdsec"
|
|
146
163
|
// Network/VPN
|
|
@@ -212,6 +229,8 @@ export interface AppDefinition {
|
|
|
212
229
|
autoSetup?: AutoSetupCapability
|
|
213
230
|
/** Use Docker's user: directive instead of PUID/PGID env vars (e.g., slskd) */
|
|
214
231
|
useDockerUser?: boolean
|
|
232
|
+
/** Path inside container where logs are stored (e.g., "/config/logs"), for bind-mounting */
|
|
233
|
+
logVolume?: string
|
|
215
234
|
}
|
|
216
235
|
|
|
217
236
|
/** Auto-setup capability for an app */
|
package/src/docker/client.ts
CHANGED
|
@@ -45,6 +45,22 @@ export async function composeDown(): Promise<{
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Run a one-off container with docker compose run --rm
|
|
50
|
+
*/
|
|
51
|
+
export async function composeRun(service: string, command: string): Promise<{ success: boolean; output: string }> {
|
|
52
|
+
try {
|
|
53
|
+
const composePath = getComposePath()
|
|
54
|
+
debugLog("Docker", `compose run --rm ${service} ${command}`)
|
|
55
|
+
const result = await $`docker compose -f ${composePath} run --rm ${service} ${command}`.text()
|
|
56
|
+
debugLog("Docker", `compose run success`)
|
|
57
|
+
return { success: true, output: result }
|
|
58
|
+
} catch (error) {
|
|
59
|
+
debugLog("Docker", `compose run failed: ${error}`)
|
|
60
|
+
return { success: false, output: String(error) }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
export async function composeRestart(service?: string): Promise<{ success: boolean; output: string }> {
|
|
49
65
|
try {
|
|
50
66
|
const composePath = getComposePath()
|
package/src/structure/manager.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises"
|
|
2
2
|
import { join } from "node:path"
|
|
3
3
|
import type { EasiarrConfig, AppId } from "../config/schema"
|
|
4
|
+
import { getApp } from "../apps/registry"
|
|
4
5
|
|
|
5
6
|
const BASE_DIRS = ["torrents", "usenet", "media"]
|
|
6
7
|
|
|
@@ -17,9 +18,11 @@ const CONTENT_TYPE_MAP: Partial<Record<AppId, string>> = {
|
|
|
17
18
|
export async function ensureDirectoryStructure(config: EasiarrConfig): Promise<void> {
|
|
18
19
|
try {
|
|
19
20
|
const dataRoot = join(config.rootDir, "data")
|
|
21
|
+
const configRoot = join(config.rootDir, "config")
|
|
20
22
|
|
|
21
|
-
// Create base
|
|
23
|
+
// Create base directories
|
|
22
24
|
await mkdir(dataRoot, { recursive: true })
|
|
25
|
+
await mkdir(configRoot, { recursive: true })
|
|
23
26
|
|
|
24
27
|
// 1. Create Base Directories (torrents, usenet, media)
|
|
25
28
|
for (const dir of BASE_DIRS) {
|
|
@@ -74,6 +77,43 @@ export async function ensureDirectoryStructure(config: EasiarrConfig): Promise<v
|
|
|
74
77
|
await mkdir(join(dataRoot, "media", "audiobooks"), { recursive: true })
|
|
75
78
|
await mkdir(join(dataRoot, "media", "podcasts"), { recursive: true })
|
|
76
79
|
}
|
|
80
|
+
|
|
81
|
+
// 4. Create config directories for enabled apps
|
|
82
|
+
// Dynamically check each app's volume definitions to see if it needs a config dir
|
|
83
|
+
for (const appConfig of config.apps) {
|
|
84
|
+
if (appConfig.enabled) {
|
|
85
|
+
const appDef = getApp(appConfig.id)
|
|
86
|
+
if (appDef) {
|
|
87
|
+
// Check if app has volume that maps to config dir
|
|
88
|
+
const volumes = appDef.volumes("$ROOT")
|
|
89
|
+
const hasConfigVolume = volumes.some((v) => v.includes("/config/"))
|
|
90
|
+
if (hasConfigVolume) {
|
|
91
|
+
await mkdir(join(configRoot, appConfig.id), { recursive: true })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Special: Traefik needs letsencrypt subdirectory
|
|
98
|
+
if (enabledApps.has("traefik")) {
|
|
99
|
+
await mkdir(join(configRoot, "traefik", "letsencrypt"), { recursive: true })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 5. Create log directories if logMount is enabled
|
|
103
|
+
if (config.logMount) {
|
|
104
|
+
const logsRoot = join(config.rootDir, "logs")
|
|
105
|
+
await mkdir(logsRoot, { recursive: true })
|
|
106
|
+
|
|
107
|
+
// Create log directory for each enabled app that has logVolume defined
|
|
108
|
+
for (const appConfig of config.apps) {
|
|
109
|
+
if (appConfig.enabled) {
|
|
110
|
+
const appDef = getApp(appConfig.id)
|
|
111
|
+
if (appDef?.logVolume) {
|
|
112
|
+
await mkdir(join(logsRoot, appConfig.id), { recursive: true })
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
77
117
|
} catch (error: unknown) {
|
|
78
118
|
const err = error as { code?: string; message: string }
|
|
79
119
|
if (err.code === "EACCES") {
|
|
@@ -23,10 +23,12 @@ import { TautulliClient } from "../../api/tautulli-api"
|
|
|
23
23
|
import { HomarrClient } from "../../api/homarr-api"
|
|
24
24
|
import { HeimdallClient } from "../../api/heimdall-api"
|
|
25
25
|
import { HuntarrClient } from "../../api/huntarr-api"
|
|
26
|
+
import { ProfilarrApiClient } from "../../api/profilarr-api"
|
|
26
27
|
import { saveConfig } from "../../config"
|
|
27
28
|
import { saveCompose } from "../../compose"
|
|
28
29
|
import { generateSlskdConfig, getSlskdConfigPath } from "../../config/slskd-config"
|
|
29
30
|
import { generateSoularrConfig, getSoularrConfigPath } from "../../config/soularr-config"
|
|
31
|
+
import { saveRecyclarrConfig } from "../../config/recyclarr-config"
|
|
30
32
|
import { writeFile, mkdir } from "fs/promises"
|
|
31
33
|
import { dirname } from "path"
|
|
32
34
|
import { existsSync } from "fs"
|
|
@@ -118,6 +120,8 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
118
120
|
{ name: "Huntarr", status: "pending" },
|
|
119
121
|
{ name: "Slskd", status: "pending" },
|
|
120
122
|
{ name: "Soularr", status: "pending" },
|
|
123
|
+
{ name: "Recyclarr", status: "pending" },
|
|
124
|
+
{ name: "Profilarr", status: "pending" },
|
|
121
125
|
{ name: "Cloudflare Tunnel", status: "pending" },
|
|
122
126
|
]
|
|
123
127
|
}
|
|
@@ -215,7 +219,13 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
215
219
|
// Step 20: Soularr (Lidarr -> Slskd bridge)
|
|
216
220
|
await this.setupSoularr()
|
|
217
221
|
|
|
218
|
-
// Step 21:
|
|
222
|
+
// Step 21: Recyclarr (TRaSH Guides sync)
|
|
223
|
+
await this.setupRecyclarr()
|
|
224
|
+
|
|
225
|
+
// Step 22: Profilarr (Alternative TRaSH Guides sync)
|
|
226
|
+
await this.setupProfilarr()
|
|
227
|
+
|
|
228
|
+
// Step 22: Cloudflare Tunnel
|
|
219
229
|
await this.setupCloudflare()
|
|
220
230
|
|
|
221
231
|
this.isRunning = false
|
|
@@ -1384,6 +1394,118 @@ export class FullAutoSetup extends BoxRenderable {
|
|
|
1384
1394
|
this.refreshContent()
|
|
1385
1395
|
}
|
|
1386
1396
|
|
|
1397
|
+
private async setupRecyclarr(): Promise<void> {
|
|
1398
|
+
this.updateStep("Recyclarr", "running")
|
|
1399
|
+
this.refreshContent()
|
|
1400
|
+
|
|
1401
|
+
const recyclarrConfig = this.config.apps.find((a) => a.id === "recyclarr" && a.enabled)
|
|
1402
|
+
if (!recyclarrConfig) {
|
|
1403
|
+
this.updateStep("Recyclarr", "skipped", "Not enabled")
|
|
1404
|
+
this.refreshContent()
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Check if we have at least one *arr app with API key
|
|
1409
|
+
const radarrApiKey = this.env["API_KEY_RADARR"]
|
|
1410
|
+
const sonarrApiKey = this.env["API_KEY_SONARR"]
|
|
1411
|
+
|
|
1412
|
+
if (!radarrApiKey && !sonarrApiKey) {
|
|
1413
|
+
this.updateStep("Recyclarr", "skipped", "No Radarr/Sonarr API keys")
|
|
1414
|
+
this.refreshContent()
|
|
1415
|
+
return
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
try {
|
|
1419
|
+
const configPath = await saveRecyclarrConfig(this.config)
|
|
1420
|
+
debugLog("FullAutoSetup", `Generated recyclarr.yml at ${configPath}`)
|
|
1421
|
+
this.updateStep("Recyclarr", "success", "Config generated")
|
|
1422
|
+
} catch (e) {
|
|
1423
|
+
this.updateStep("Recyclarr", "error", `${e}`)
|
|
1424
|
+
}
|
|
1425
|
+
this.refreshContent()
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
private async setupProfilarr(): Promise<void> {
|
|
1429
|
+
this.updateStep("Profilarr", "running")
|
|
1430
|
+
this.refreshContent()
|
|
1431
|
+
|
|
1432
|
+
const profilarrConfig = this.config.apps.find((a) => a.id === "profilarr" && a.enabled)
|
|
1433
|
+
if (!profilarrConfig) {
|
|
1434
|
+
this.updateStep("Profilarr", "skipped", "Not enabled")
|
|
1435
|
+
this.refreshContent()
|
|
1436
|
+
return
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
try {
|
|
1440
|
+
const port = profilarrConfig.port || 6868
|
|
1441
|
+
const url = getApplicationUrl("profilarr", port, this.config)
|
|
1442
|
+
|
|
1443
|
+
this.updateStep("Profilarr", "running", "Waiting for Profilarr...")
|
|
1444
|
+
this.refreshContent()
|
|
1445
|
+
|
|
1446
|
+
const client = new ProfilarrApiClient("localhost", port)
|
|
1447
|
+
|
|
1448
|
+
// Wait for health
|
|
1449
|
+
let healthy = false
|
|
1450
|
+
for (let i = 0; i < 12; i++) {
|
|
1451
|
+
if (await client.isHealthy()) {
|
|
1452
|
+
healthy = true
|
|
1453
|
+
break
|
|
1454
|
+
}
|
|
1455
|
+
await new Promise((resolve) => setTimeout(resolve, 5000))
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
if (!healthy) {
|
|
1459
|
+
throw new Error("Timed out waiting for Profilarr API")
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
this.updateStep("Profilarr", "running", "Configuring...")
|
|
1463
|
+
|
|
1464
|
+
const result = await client.setup({
|
|
1465
|
+
username: this.globalUsername,
|
|
1466
|
+
password: this.globalPassword,
|
|
1467
|
+
env: this.env,
|
|
1468
|
+
})
|
|
1469
|
+
|
|
1470
|
+
if (!result.success) {
|
|
1471
|
+
throw new Error(result.message)
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Save API key
|
|
1475
|
+
if (result.envUpdates) {
|
|
1476
|
+
await updateEnv(result.envUpdates)
|
|
1477
|
+
Object.assign(this.env, result.envUpdates)
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Configure Radarr/Sonarr connections after auth setup
|
|
1481
|
+
const radarrConfig = this.config.apps.find((a) => a.id === "radarr" && a.enabled)
|
|
1482
|
+
if (radarrConfig && this.env["API_KEY_RADARR"]) {
|
|
1483
|
+
try {
|
|
1484
|
+
await client.configureRadarr("radarr", radarrConfig.port || 7878, this.env["API_KEY_RADARR"])
|
|
1485
|
+
debugLog("FullAutoSetup", "Profilarr: Radarr configured")
|
|
1486
|
+
} catch {
|
|
1487
|
+
/* Radarr config failed */
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const sonarrConfig = this.config.apps.find((a) => a.id === "sonarr" && a.enabled)
|
|
1492
|
+
if (sonarrConfig && this.env["API_KEY_SONARR"]) {
|
|
1493
|
+
try {
|
|
1494
|
+
await client.configureSonarr("sonarr", sonarrConfig.port || 8989, this.env["API_KEY_SONARR"])
|
|
1495
|
+
debugLog("FullAutoSetup", "Profilarr: Sonarr configured")
|
|
1496
|
+
} catch {
|
|
1497
|
+
/* Sonarr config failed */
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
this.updateStep("Profilarr", "success", `Configured - ${url}`)
|
|
1502
|
+
} catch (e) {
|
|
1503
|
+
debugLog("FullAutoSetup", `Profilarr setup error: ${e}`)
|
|
1504
|
+
this.updateStep("Profilarr", "error", `${e}`)
|
|
1505
|
+
}
|
|
1506
|
+
this.refreshContent()
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1387
1509
|
private updateStep(name: string, status: SetupStep["status"], message?: string): void {
|
|
1388
1510
|
const step = this.steps.find((s) => s.name === name)
|
|
1389
1511
|
if (step) {
|