@muhammedaksam/easiarr 0.7.9 → 0.8.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.7.9",
3
+ "version": "0.8.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",
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Jellyfin API Client
3
+ * Handles setup wizard automation and media library management
4
+ */
5
+
6
+ import { debugLog } from "../utils/debug"
7
+
8
+ // ==========================================
9
+ // Startup Wizard Types
10
+ // ==========================================
11
+
12
+ export interface StartupConfiguration {
13
+ UICulture?: string
14
+ MetadataCountryCode?: string
15
+ PreferredMetadataLanguage?: string
16
+ }
17
+
18
+ export interface StartupUser {
19
+ Name: string
20
+ Pw: string
21
+ }
22
+
23
+ export interface StartupRemoteAccess {
24
+ EnableRemoteAccess: boolean
25
+ EnableAutomaticPortMapping: boolean
26
+ }
27
+
28
+ // ==========================================
29
+ // Library Types
30
+ // ==========================================
31
+
32
+ export interface VirtualFolderInfo {
33
+ Name: string
34
+ Locations: string[]
35
+ CollectionType: LibraryType
36
+ ItemId?: string
37
+ }
38
+
39
+ export type LibraryType =
40
+ | "movies"
41
+ | "tvshows"
42
+ | "music"
43
+ | "books"
44
+ | "homevideos"
45
+ | "musicvideos"
46
+ | "photos"
47
+ | "playlists"
48
+ | "boxsets"
49
+
50
+ export interface AddVirtualFolderOptions {
51
+ name: string
52
+ collectionType: LibraryType
53
+ paths: string[]
54
+ refreshLibrary?: boolean
55
+ }
56
+
57
+ // ==========================================
58
+ // System Types
59
+ // ==========================================
60
+
61
+ export interface SystemInfo {
62
+ ServerName: string
63
+ Version: string
64
+ Id: string
65
+ OperatingSystem: string
66
+ StartupWizardCompleted: boolean
67
+ }
68
+
69
+ export interface AuthResult {
70
+ AccessToken: string
71
+ ServerId: string
72
+ User: {
73
+ Id: string
74
+ Name: string
75
+ }
76
+ }
77
+
78
+ // ==========================================
79
+ // Jellyfin Client
80
+ // ==========================================
81
+
82
+ export class JellyfinClient {
83
+ private baseUrl: string
84
+ private accessToken?: string
85
+
86
+ constructor(host: string, port: number, accessToken?: string) {
87
+ this.baseUrl = `http://${host}:${port}`
88
+ this.accessToken = accessToken
89
+ }
90
+
91
+ private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
92
+ const url = `${this.baseUrl}${endpoint}`
93
+ const headers: Record<string, string> = {
94
+ "Content-Type": "application/json",
95
+ // Jellyfin requires client identification
96
+ "X-Emby-Authorization":
97
+ 'MediaBrowser Client="Easiarr", Device="Server", DeviceId="easiarr-setup", Version="1.0.0"' +
98
+ (this.accessToken ? `, Token="${this.accessToken}"` : ""),
99
+ ...((options.headers as Record<string, string>) || {}),
100
+ }
101
+
102
+ debugLog("JellyfinAPI", `${options.method || "GET"} ${url}`)
103
+ if (options.body) {
104
+ debugLog("JellyfinAPI", `Request Body: ${options.body}`)
105
+ }
106
+
107
+ const response = await fetch(url, { ...options, headers })
108
+ const text = await response.text()
109
+
110
+ debugLog("JellyfinAPI", `Response ${response.status} from ${endpoint}`)
111
+ if (text && text.length < 2000) {
112
+ debugLog("JellyfinAPI", `Response Body: ${text}`)
113
+ }
114
+
115
+ if (!response.ok) {
116
+ throw new Error(`Jellyfin API request failed: ${response.status} ${response.statusText} - ${text}`)
117
+ }
118
+
119
+ if (!text) return {} as T
120
+ return JSON.parse(text) as T
121
+ }
122
+
123
+ // ==========================================
124
+ // Setup Wizard Methods (no auth required)
125
+ // ==========================================
126
+
127
+ /**
128
+ * Check if the startup wizard has been completed
129
+ */
130
+ async isStartupComplete(): Promise<boolean> {
131
+ try {
132
+ const info = await this.request<SystemInfo>("/System/Info/Public")
133
+ return info.StartupWizardCompleted === true
134
+ } catch {
135
+ return false
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Get current startup configuration
141
+ */
142
+ async getStartupConfiguration(): Promise<StartupConfiguration> {
143
+ return this.request<StartupConfiguration>("/Startup/Configuration")
144
+ }
145
+
146
+ /**
147
+ * Set startup configuration (metadata language, UI culture)
148
+ */
149
+ async setStartupConfiguration(config: StartupConfiguration): Promise<void> {
150
+ await this.request("/Startup/Configuration", {
151
+ method: "POST",
152
+ body: JSON.stringify(config),
153
+ })
154
+ }
155
+
156
+ /**
157
+ * Create the initial admin user
158
+ */
159
+ async createAdminUser(name: string, password: string): Promise<void> {
160
+ const user: StartupUser = { Name: name, Pw: password }
161
+ await this.request("/Startup/User", {
162
+ method: "POST",
163
+ body: JSON.stringify(user),
164
+ })
165
+ }
166
+
167
+ /**
168
+ * Configure remote access settings
169
+ */
170
+ async setRemoteAccess(enableRemote: boolean, enableUPnP: boolean = false): Promise<void> {
171
+ const config: StartupRemoteAccess = {
172
+ EnableRemoteAccess: enableRemote,
173
+ EnableAutomaticPortMapping: enableUPnP,
174
+ }
175
+ await this.request("/Startup/RemoteAccess", {
176
+ method: "POST",
177
+ body: JSON.stringify(config),
178
+ })
179
+ }
180
+
181
+ /**
182
+ * Complete the startup wizard
183
+ */
184
+ async completeStartup(): Promise<void> {
185
+ await this.request("/Startup/Complete", {
186
+ method: "POST",
187
+ })
188
+ }
189
+
190
+ /**
191
+ * Run the full setup wizard with sensible defaults
192
+ */
193
+ async runSetupWizard(
194
+ adminName: string,
195
+ adminPassword: string,
196
+ options: {
197
+ uiCulture?: string
198
+ metadataCountry?: string
199
+ metadataLanguage?: string
200
+ enableRemoteAccess?: boolean
201
+ enableUPnP?: boolean
202
+ } = {}
203
+ ): Promise<void> {
204
+ const {
205
+ uiCulture = "en-US",
206
+ metadataCountry = "US",
207
+ metadataLanguage = "en",
208
+ enableRemoteAccess = true,
209
+ enableUPnP = false,
210
+ } = options
211
+
212
+ // Step 1: Set UI culture and metadata language
213
+ await this.setStartupConfiguration({
214
+ UICulture: uiCulture,
215
+ MetadataCountryCode: metadataCountry,
216
+ PreferredMetadataLanguage: metadataLanguage,
217
+ })
218
+
219
+ // Step 2: Create admin user
220
+ await this.createAdminUser(adminName, adminPassword)
221
+
222
+ // Step 3: Configure remote access
223
+ await this.setRemoteAccess(enableRemoteAccess, enableUPnP)
224
+
225
+ // Step 4: Complete the wizard
226
+ await this.completeStartup()
227
+ }
228
+
229
+ // ==========================================
230
+ // Authentication (post-setup)
231
+ // ==========================================
232
+
233
+ /**
234
+ * Authenticate with username/password and get access token
235
+ */
236
+ async authenticate(username: string, password: string): Promise<AuthResult> {
237
+ const result = await this.request<AuthResult>("/Users/AuthenticateByName", {
238
+ method: "POST",
239
+ body: JSON.stringify({
240
+ Username: username,
241
+ Pw: password,
242
+ }),
243
+ })
244
+
245
+ // Store token for subsequent requests
246
+ this.accessToken = result.AccessToken
247
+ return result
248
+ }
249
+
250
+ /**
251
+ * Set access token directly (if already known)
252
+ */
253
+ setAccessToken(token: string): void {
254
+ this.accessToken = token
255
+ }
256
+
257
+ // ==========================================
258
+ // Library Management (requires auth)
259
+ // ==========================================
260
+
261
+ /**
262
+ * Get all virtual folders (media libraries)
263
+ */
264
+ async getVirtualFolders(): Promise<VirtualFolderInfo[]> {
265
+ return this.request<VirtualFolderInfo[]>("/Library/VirtualFolders")
266
+ }
267
+
268
+ /**
269
+ * Add a new media library
270
+ */
271
+ async addVirtualFolder(options: AddVirtualFolderOptions): Promise<void> {
272
+ const params = new URLSearchParams({
273
+ name: options.name,
274
+ collectionType: options.collectionType,
275
+ refreshLibrary: String(options.refreshLibrary ?? true),
276
+ })
277
+
278
+ // Paths need to be added to the body
279
+ await this.request(`/Library/VirtualFolders?${params.toString()}`, {
280
+ method: "POST",
281
+ body: JSON.stringify({
282
+ LibraryOptions: {
283
+ PathInfos: options.paths.map((path) => ({ Path: path })),
284
+ },
285
+ }),
286
+ })
287
+ }
288
+
289
+ /**
290
+ * Add default media libraries based on common media stack paths
291
+ */
292
+ async addDefaultLibraries(): Promise<void> {
293
+ const defaultLibraries: AddVirtualFolderOptions[] = [
294
+ { name: "Movies", collectionType: "movies", paths: ["/data/media/movies"] },
295
+ { name: "TV Shows", collectionType: "tvshows", paths: ["/data/media/tv"] },
296
+ { name: "Music", collectionType: "music", paths: ["/data/media/music"] },
297
+ ]
298
+
299
+ for (const lib of defaultLibraries) {
300
+ try {
301
+ await this.addVirtualFolder(lib)
302
+ } catch (error) {
303
+ // Library might already exist, continue with others
304
+ debugLog("JellyfinAPI", `Failed to add library ${lib.name}: ${error}`)
305
+ }
306
+ }
307
+ }
308
+
309
+ // ==========================================
310
+ // API Key Management (requires auth)
311
+ // ==========================================
312
+
313
+ /**
314
+ * Create an API key for external access (e.g., Homepage widget)
315
+ */
316
+ async createApiKey(appName: string): Promise<string> {
317
+ await this.request(`/Auth/Keys?app=${encodeURIComponent(appName)}`, {
318
+ method: "POST",
319
+ })
320
+
321
+ // Get all keys and find the one we just created
322
+ const keys = await this.getApiKeys()
323
+ const key = keys.find((k) => k.AppName === appName)
324
+ return key?.AccessToken || ""
325
+ }
326
+
327
+ /**
328
+ * Get all API keys
329
+ */
330
+ async getApiKeys(): Promise<{ AccessToken: string; AppName: string; DateCreated: string }[]> {
331
+ const result = await this.request<{
332
+ Items: { AccessToken: string; AppName: string; DateCreated: string }[]
333
+ }>("/Auth/Keys")
334
+ return result.Items || []
335
+ }
336
+
337
+ // ==========================================
338
+ // Health Check
339
+ // ==========================================
340
+
341
+ /**
342
+ * Check if Jellyfin is running and accessible
343
+ */
344
+ async isHealthy(): Promise<boolean> {
345
+ try {
346
+ await this.request<SystemInfo>("/System/Info/Public")
347
+ return true
348
+ } catch {
349
+ return false
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Get public system info (no auth required)
355
+ */
356
+ async getPublicSystemInfo(): Promise<SystemInfo> {
357
+ return this.request<SystemInfo>("/System/Info/Public")
358
+ }
359
+ }
@@ -397,7 +397,7 @@ export const APPS: Record<AppId, AppDefinition> = {
397
397
  name: "Jellyseerr",
398
398
  description: "Request management for Jellyfin",
399
399
  category: "request",
400
- defaultPort: 5056,
400
+ defaultPort: 5055,
401
401
  image: "fallenbagel/jellyseerr:latest",
402
402
  puid: 13012,
403
403
  pgid: 13000,
@@ -188,7 +188,7 @@ export class ApiKeyViewer extends BoxRenderable {
188
188
  }
189
189
 
190
190
  // Will attempt to initialize/login when saving
191
- const globalPassword = env["PASSWORD_GLOBAL"]
191
+ const globalPassword = env["PASSWORD_GLOBAL"] || "Ch4ng3m3!1234securityReasons"
192
192
  if (!globalPassword) {
193
193
  this.keys.push({
194
194
  appId: "portainer",
@@ -439,7 +439,7 @@ export class ApiKeyViewer extends BoxRenderable {
439
439
  private async initializePortainer(_updates: Record<string, string>) {
440
440
  const env = readEnvSync()
441
441
  const globalUsername = env["USERNAME_GLOBAL"] || "admin"
442
- const globalPassword = env["PASSWORD_GLOBAL"]
442
+ const globalPassword = env["PASSWORD_GLOBAL"] || "Ch4ng3m3!1234securityReasons"
443
443
 
444
444
  if (!globalPassword) return
445
445
 
@@ -84,9 +84,9 @@ export class AppConfigurator extends BoxRenderable {
84
84
  private loadSavedCredentials() {
85
85
  const env = readEnvSync()
86
86
  if (env.USERNAME_GLOBAL) this.globalUsername = env.USERNAME_GLOBAL
87
- if (env.PASSWORD_GLOBAL) this.globalPassword = env.PASSWORD_GLOBAL
87
+ this.globalPassword = env.PASSWORD_GLOBAL || "Ch4ng3m3!1234securityReasons"
88
88
  if (env.PASSWORD_QBITTORRENT) this.qbPass = env.PASSWORD_QBITTORRENT
89
- if (env.SABNZBD_API_KEY) this.sabApiKey = env.SABNZBD_API_KEY
89
+ if (env.API_KEY_SABNZBD) this.sabApiKey = env.API_KEY_SABNZBD
90
90
  }
91
91
 
92
92
  private renderCredentialsPrompt() {
@@ -612,7 +612,7 @@ export class AppConfigurator extends BoxRenderable {
612
612
  if (type === "qbittorrent" && this.qbPass) {
613
613
  updates.PASSWORD_QBITTORRENT = this.qbPass
614
614
  } else if (type === "sabnzbd" && this.sabApiKey) {
615
- updates.SABNZBD_API_KEY = this.sabApiKey
615
+ updates.API_KEY_SABNZBD = this.sabApiKey
616
616
  }
617
617
  await updateEnv(updates)
618
618
  } catch {
@@ -10,6 +10,7 @@ import { ArrApiClient, type AddRootFolderOptions } from "../../api/arr-api"
10
10
  import { ProwlarrClient, type ArrAppType } from "../../api/prowlarr-api"
11
11
  import { QBittorrentClient, type QBittorrentCategory } from "../../api/qbittorrent-api"
12
12
  import { PortainerApiClient } from "../../api/portainer-api"
13
+ import { JellyfinClient } from "../../api/jellyfin-api"
13
14
  import { getApp } from "../../apps/registry"
14
15
  // import type { AppId } from "../../config/schema"
15
16
  import { getCategoriesForApps } from "../../utils/categories"
@@ -66,7 +67,7 @@ export class FullAutoSetup extends BoxRenderable {
66
67
 
67
68
  this.env = readEnvSync()
68
69
  this.globalUsername = this.env["USERNAME_GLOBAL"] || "admin"
69
- this.globalPassword = this.env["PASSWORD_GLOBAL"] || ""
70
+ this.globalPassword = this.env["PASSWORD_GLOBAL"] || "Ch4ng3m3!1234securityReasons"
70
71
 
71
72
  this.initKeyHandler()
72
73
  this.initSteps()
@@ -81,6 +82,7 @@ export class FullAutoSetup extends BoxRenderable {
81
82
  { name: "FlareSolverr", status: "pending" },
82
83
  { name: "qBittorrent", status: "pending" },
83
84
  { name: "Portainer", status: "pending" },
85
+ { name: "Jellyfin", status: "pending" },
84
86
  ]
85
87
  }
86
88
 
@@ -129,6 +131,9 @@ export class FullAutoSetup extends BoxRenderable {
129
131
  // Step 6: Portainer
130
132
  await this.setupPortainer()
131
133
 
134
+ // Step 7: Jellyfin
135
+ await this.setupJellyfin()
136
+
132
137
  this.isRunning = false
133
138
  this.isDone = true
134
139
  this.refreshContent()
@@ -407,6 +412,46 @@ export class FullAutoSetup extends BoxRenderable {
407
412
  this.refreshContent()
408
413
  }
409
414
 
415
+ private async setupJellyfin(): Promise<void> {
416
+ this.updateStep("Jellyfin", "running")
417
+ this.refreshContent()
418
+
419
+ const jellyfinConfig = this.config.apps.find((a) => a.id === "jellyfin" && a.enabled)
420
+ if (!jellyfinConfig) {
421
+ this.updateStep("Jellyfin", "skipped", "Not enabled")
422
+ this.refreshContent()
423
+ return
424
+ }
425
+
426
+ try {
427
+ const port = jellyfinConfig.port || 8096
428
+ const client = new JellyfinClient("localhost", port)
429
+
430
+ // Check if reachable
431
+ const healthy = await client.isHealthy()
432
+ if (!healthy) {
433
+ this.updateStep("Jellyfin", "skipped", "Not reachable yet")
434
+ this.refreshContent()
435
+ return
436
+ }
437
+
438
+ // Check if already set up
439
+ const isComplete = await client.isStartupComplete()
440
+ if (isComplete) {
441
+ this.updateStep("Jellyfin", "skipped", "Already configured")
442
+ this.refreshContent()
443
+ return
444
+ }
445
+
446
+ // Run setup wizard
447
+ await client.runSetupWizard(this.globalUsername, this.globalPassword)
448
+ this.updateStep("Jellyfin", "success", "Setup wizard completed")
449
+ } catch (e) {
450
+ this.updateStep("Jellyfin", "error", `${e}`)
451
+ }
452
+ this.refreshContent()
453
+ }
454
+
410
455
  private updateStep(name: string, status: SetupStep["status"], message?: string): void {
411
456
  const step = this.steps.find((s) => s.name === name)
412
457
  if (step) {
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Jellyfin Setup Screen
3
+ * Automates the Jellyfin setup wizard via API
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 { JellyfinClient } from "../../api/jellyfin-api"
10
+ import { readEnvSync, writeEnvSync } from "../../utils/env"
11
+ import { debugLog } from "../../utils/debug"
12
+
13
+ interface SetupResult {
14
+ name: string
15
+ status: "pending" | "configuring" | "success" | "error" | "skipped"
16
+ message?: string
17
+ }
18
+
19
+ type Step = "menu" | "running" | "done"
20
+
21
+ export class JellyfinSetup extends BoxRenderable {
22
+ private config: EasiarrConfig
23
+ private cliRenderer: CliRenderer
24
+ private onBack: () => void
25
+ private keyHandler!: (key: KeyEvent) => void
26
+ private results: SetupResult[] = []
27
+ private currentStep: Step = "menu"
28
+ private contentBox!: BoxRenderable
29
+ private menuIndex = 0
30
+ private jellyfinClient: JellyfinClient | null = null
31
+
32
+ constructor(cliRenderer: CliRenderer, config: EasiarrConfig, onBack: () => void) {
33
+ const { container: pageContainer, content: contentBox } = createPageLayout(cliRenderer, {
34
+ title: "Jellyfin Setup",
35
+ stepInfo: "Configure Jellyfin via API",
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
+
50
+ this.initJellyfinClient()
51
+ this.initKeyHandler()
52
+ this.refreshContent()
53
+ }
54
+
55
+ private initJellyfinClient(): void {
56
+ const jellyfinConfig = this.config.apps.find((a) => a.id === "jellyfin")
57
+ if (jellyfinConfig?.enabled) {
58
+ const port = jellyfinConfig.port || 8096
59
+ this.jellyfinClient = new JellyfinClient("localhost", port)
60
+ }
61
+ }
62
+
63
+ private initKeyHandler(): void {
64
+ this.keyHandler = (key: KeyEvent) => {
65
+ debugLog("Jellyfin", `Key: ${key.name}, step=${this.currentStep}`)
66
+
67
+ if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
68
+ if (this.currentStep === "menu") {
69
+ this.cleanup()
70
+ } else if (this.currentStep === "done") {
71
+ this.currentStep = "menu"
72
+ this.refreshContent()
73
+ }
74
+ return
75
+ }
76
+
77
+ if (this.currentStep === "menu") {
78
+ this.handleMenuKeys(key)
79
+ } else if (this.currentStep === "done") {
80
+ if (key.name === "return" || key.name === "escape") {
81
+ this.currentStep = "menu"
82
+ this.refreshContent()
83
+ }
84
+ }
85
+ }
86
+ this.cliRenderer.keyInput.on("keypress", this.keyHandler)
87
+ debugLog("Jellyfin", "Key handler registered")
88
+ }
89
+
90
+ private handleMenuKeys(key: KeyEvent): void {
91
+ const menuItems = this.getMenuItems()
92
+
93
+ if (key.name === "up" && this.menuIndex > 0) {
94
+ this.menuIndex--
95
+ this.refreshContent()
96
+ } else if (key.name === "down" && this.menuIndex < menuItems.length - 1) {
97
+ this.menuIndex++
98
+ this.refreshContent()
99
+ } else if (key.name === "return") {
100
+ this.executeMenuItem(this.menuIndex)
101
+ }
102
+ }
103
+
104
+ private getMenuItems(): { name: string; description: string; action: () => void }[] {
105
+ return [
106
+ {
107
+ name: "🚀 Run Setup Wizard",
108
+ description: "Create admin user and complete initial setup",
109
+ action: () => this.runSetupWizard(),
110
+ },
111
+ {
112
+ name: "📚 Add Default Libraries",
113
+ description: "Add Movies, TV Shows, Music libraries",
114
+ action: () => this.addDefaultLibraries(),
115
+ },
116
+ {
117
+ name: "🔑 Generate API Key",
118
+ description: "Create API key for Homepage widget",
119
+ action: () => this.generateApiKey(),
120
+ },
121
+ {
122
+ name: "↩️ Back",
123
+ description: "Return to main menu",
124
+ action: () => this.cleanup(),
125
+ },
126
+ ]
127
+ }
128
+
129
+ private executeMenuItem(index: number): void {
130
+ const items = this.getMenuItems()
131
+ if (index >= 0 && index < items.length) {
132
+ items[index].action()
133
+ }
134
+ }
135
+
136
+ private async runSetupWizard(): Promise<void> {
137
+ if (!this.jellyfinClient) {
138
+ this.results = [{ name: "Jellyfin", status: "error", message: "Not enabled in config" }]
139
+ this.currentStep = "done"
140
+ this.refreshContent()
141
+ return
142
+ }
143
+
144
+ this.currentStep = "running"
145
+ this.results = [
146
+ { name: "Check status", status: "configuring" },
147
+ { name: "Set metadata language", status: "pending" },
148
+ { name: "Create admin user", status: "pending" },
149
+ { name: "Configure remote access", status: "pending" },
150
+ { name: "Complete wizard", status: "pending" },
151
+ ]
152
+ this.refreshContent()
153
+
154
+ try {
155
+ // Step 1: Check if already set up
156
+ const isComplete = await this.jellyfinClient.isStartupComplete()
157
+ if (isComplete) {
158
+ this.results[0].status = "skipped"
159
+ this.results[0].message = "Already configured"
160
+ this.results.slice(1).forEach((r) => {
161
+ r.status = "skipped"
162
+ r.message = "Wizard already completed"
163
+ })
164
+ this.currentStep = "done"
165
+ this.refreshContent()
166
+ return
167
+ }
168
+ this.results[0].status = "success"
169
+ this.results[0].message = "Wizard needed"
170
+ this.refreshContent()
171
+
172
+ // Step 2: Set metadata language
173
+ this.results[1].status = "configuring"
174
+ this.refreshContent()
175
+ await this.jellyfinClient.setStartupConfiguration({
176
+ UICulture: "en-US",
177
+ MetadataCountryCode: "US",
178
+ PreferredMetadataLanguage: "en",
179
+ })
180
+ this.results[1].status = "success"
181
+ this.refreshContent()
182
+
183
+ // Step 3: Create admin user
184
+ this.results[2].status = "configuring"
185
+ this.refreshContent()
186
+ const env = readEnvSync()
187
+ const username = env["USERNAME_GLOBAL"] || "admin"
188
+ const password = env["PASSWORD_GLOBAL"] || "Ch4ng3m3!1234securityReasons"
189
+ await this.jellyfinClient.createAdminUser(username, password)
190
+ this.results[2].status = "success"
191
+ this.results[2].message = `User: ${username}`
192
+ this.refreshContent()
193
+
194
+ // Step 4: Configure remote access
195
+ this.results[3].status = "configuring"
196
+ this.refreshContent()
197
+ await this.jellyfinClient.setRemoteAccess(true, false)
198
+ this.results[3].status = "success"
199
+ this.refreshContent()
200
+
201
+ // Step 5: Complete wizard
202
+ this.results[4].status = "configuring"
203
+ this.refreshContent()
204
+ await this.jellyfinClient.completeStartup()
205
+ this.results[4].status = "success"
206
+ this.refreshContent()
207
+ } catch (error) {
208
+ const current = this.results.find((r) => r.status === "configuring")
209
+ if (current) {
210
+ current.status = "error"
211
+ current.message = error instanceof Error ? error.message : String(error)
212
+ }
213
+ }
214
+
215
+ this.currentStep = "done"
216
+ this.refreshContent()
217
+ }
218
+
219
+ private async addDefaultLibraries(): Promise<void> {
220
+ if (!this.jellyfinClient) {
221
+ this.results = [{ name: "Jellyfin", status: "error", message: "Not enabled in config" }]
222
+ this.currentStep = "done"
223
+ this.refreshContent()
224
+ return
225
+ }
226
+
227
+ this.currentStep = "running"
228
+ this.results = [
229
+ { name: "Authenticate", status: "configuring" },
230
+ { name: "Movies", status: "pending" },
231
+ { name: "TV Shows", status: "pending" },
232
+ { name: "Music", status: "pending" },
233
+ ]
234
+ this.refreshContent()
235
+
236
+ try {
237
+ // Authenticate first
238
+ const env = readEnvSync()
239
+ const username = env["USERNAME_GLOBAL"] || "admin"
240
+ const password = env["PASSWORD_GLOBAL"] || "Ch4ng3m3!1234securityReasons"
241
+ await this.jellyfinClient.authenticate(username, password)
242
+ this.results[0].status = "success"
243
+ this.refreshContent()
244
+
245
+ // Add libraries
246
+ const libraries = [
247
+ { name: "Movies", collectionType: "movies" as const, paths: ["/data/media/movies"] },
248
+ { name: "TV Shows", collectionType: "tvshows" as const, paths: ["/data/media/tv"] },
249
+ { name: "Music", collectionType: "music" as const, paths: ["/data/media/music"] },
250
+ ]
251
+
252
+ for (let i = 0; i < libraries.length; i++) {
253
+ const lib = libraries[i]
254
+ this.results[i + 1].status = "configuring"
255
+ this.refreshContent()
256
+
257
+ try {
258
+ await this.jellyfinClient.addVirtualFolder(lib)
259
+ this.results[i + 1].status = "success"
260
+ this.results[i + 1].message = lib.paths[0]
261
+ } catch (error) {
262
+ this.results[i + 1].status = "error"
263
+ this.results[i + 1].message = error instanceof Error ? error.message : String(error)
264
+ }
265
+ this.refreshContent()
266
+ }
267
+ } catch (error) {
268
+ this.results[0].status = "error"
269
+ this.results[0].message = error instanceof Error ? error.message : String(error)
270
+ }
271
+
272
+ this.currentStep = "done"
273
+ this.refreshContent()
274
+ }
275
+
276
+ private async generateApiKey(): Promise<void> {
277
+ if (!this.jellyfinClient) {
278
+ this.results = [{ name: "Jellyfin", status: "error", message: "Not enabled in config" }]
279
+ this.currentStep = "done"
280
+ this.refreshContent()
281
+ return
282
+ }
283
+
284
+ this.currentStep = "running"
285
+ this.results = [
286
+ { name: "Authenticate", status: "configuring" },
287
+ { name: "Generate API Key", status: "pending" },
288
+ { name: "Save to .env", status: "pending" },
289
+ ]
290
+ this.refreshContent()
291
+
292
+ try {
293
+ // Authenticate first
294
+ const env = readEnvSync()
295
+ const username = env["USERNAME_GLOBAL"] || "admin"
296
+ const password = env["PASSWORD_GLOBAL"] || "Ch4ng3m3!1234securityReasons"
297
+ await this.jellyfinClient.authenticate(username, password)
298
+ this.results[0].status = "success"
299
+ this.refreshContent()
300
+
301
+ // Generate API key
302
+ this.results[1].status = "configuring"
303
+ this.refreshContent()
304
+ const apiKey = await this.jellyfinClient.createApiKey("Easiarr")
305
+ if (!apiKey) {
306
+ throw new Error("Failed to create API key")
307
+ }
308
+ this.results[1].status = "success"
309
+ this.results[1].message = `Key: ${apiKey.substring(0, 8)}...`
310
+ this.refreshContent()
311
+
312
+ // Save to .env
313
+ this.results[2].status = "configuring"
314
+ this.refreshContent()
315
+ env["API_KEY_JELLYFIN"] = apiKey
316
+ writeEnvSync(env)
317
+ this.results[2].status = "success"
318
+ this.results[2].message = "Saved as API_KEY_JELLYFIN"
319
+ this.refreshContent()
320
+ } catch (error) {
321
+ const current = this.results.find((r) => r.status === "configuring")
322
+ if (current) {
323
+ current.status = "error"
324
+ current.message = error instanceof Error ? error.message : String(error)
325
+ }
326
+ }
327
+
328
+ this.currentStep = "done"
329
+ this.refreshContent()
330
+ }
331
+
332
+ private refreshContent(): void {
333
+ this.contentBox.getChildren().forEach((child) => child.destroy())
334
+
335
+ if (this.currentStep === "menu") {
336
+ this.renderMenu()
337
+ } else {
338
+ this.renderResults()
339
+ }
340
+ }
341
+
342
+ private renderMenu(): void {
343
+ // Check health status
344
+ this.checkHealth()
345
+
346
+ this.contentBox.add(
347
+ new TextRenderable(this.cliRenderer, {
348
+ content: "Select an action:\n\n",
349
+ fg: "#aaaaaa",
350
+ })
351
+ )
352
+
353
+ this.getMenuItems().forEach((item, idx) => {
354
+ const pointer = idx === this.menuIndex ? "→ " : " "
355
+ const fg = idx === this.menuIndex ? "#50fa7b" : "#8be9fd"
356
+
357
+ this.contentBox.add(
358
+ new TextRenderable(this.cliRenderer, {
359
+ content: `${pointer}${item.name}\n`,
360
+ fg,
361
+ })
362
+ )
363
+ this.contentBox.add(
364
+ new TextRenderable(this.cliRenderer, {
365
+ content: ` ${item.description}\n\n`,
366
+ fg: "#6272a4",
367
+ })
368
+ )
369
+ })
370
+ }
371
+
372
+ private async checkHealth(): Promise<void> {
373
+ if (!this.jellyfinClient) {
374
+ this.contentBox.add(
375
+ new TextRenderable(this.cliRenderer, {
376
+ content: "⚠️ Jellyfin not enabled in config!\n\n",
377
+ fg: "#ff5555",
378
+ })
379
+ )
380
+ return
381
+ }
382
+
383
+ try {
384
+ const isHealthy = await this.jellyfinClient.isHealthy()
385
+ const isComplete = isHealthy ? await this.jellyfinClient.isStartupComplete() : false
386
+
387
+ if (!isHealthy) {
388
+ this.contentBox.add(
389
+ new TextRenderable(this.cliRenderer, {
390
+ content: "⚠️ Jellyfin is not reachable. Make sure the container is running.\n\n",
391
+ fg: "#ffb86c",
392
+ })
393
+ )
394
+ } else if (!isComplete) {
395
+ this.contentBox.add(
396
+ new TextRenderable(this.cliRenderer, {
397
+ content: "✨ Jellyfin needs initial setup. Run 'Setup Wizard' to configure.\n\n",
398
+ fg: "#50fa7b",
399
+ })
400
+ )
401
+ } else {
402
+ this.contentBox.add(
403
+ new TextRenderable(this.cliRenderer, {
404
+ content: "✓ Jellyfin is running and configured.\n\n",
405
+ fg: "#50fa7b",
406
+ })
407
+ )
408
+ }
409
+ } catch {
410
+ // Ignore errors in health check display
411
+ }
412
+ }
413
+
414
+ private renderResults(): void {
415
+ const headerText = this.currentStep === "done" ? "Results:\n\n" : "Configuring...\n\n"
416
+ this.contentBox.add(
417
+ new TextRenderable(this.cliRenderer, {
418
+ content: headerText,
419
+ fg: this.currentStep === "done" ? "#50fa7b" : "#f1fa8c",
420
+ })
421
+ )
422
+
423
+ for (const result of this.results) {
424
+ let status = ""
425
+ let fg = "#aaaaaa"
426
+ switch (result.status) {
427
+ case "pending":
428
+ status = "⏳"
429
+ break
430
+ case "configuring":
431
+ status = "🔄"
432
+ fg = "#f1fa8c"
433
+ break
434
+ case "success":
435
+ status = "✓"
436
+ fg = "#50fa7b"
437
+ break
438
+ case "error":
439
+ status = "✗"
440
+ fg = "#ff5555"
441
+ break
442
+ case "skipped":
443
+ status = "⊘"
444
+ fg = "#6272a4"
445
+ break
446
+ }
447
+
448
+ let content = `${status} ${result.name}`
449
+ if (result.message) {
450
+ content += ` - ${result.message}`
451
+ }
452
+
453
+ this.contentBox.add(new TextRenderable(this.cliRenderer, { content: content + "\n", fg }))
454
+ }
455
+
456
+ if (this.currentStep === "done") {
457
+ this.contentBox.add(
458
+ new TextRenderable(this.cliRenderer, {
459
+ content: "\nPress Enter or Esc to continue...",
460
+ fg: "#6272a4",
461
+ })
462
+ )
463
+ }
464
+ }
465
+
466
+ private cleanup(): void {
467
+ this.cliRenderer.keyInput.off("keypress", this.keyHandler)
468
+ this.destroy()
469
+ this.onBack()
470
+ }
471
+ }
@@ -17,6 +17,7 @@ import { QBittorrentSetup } from "./QBittorrentSetup"
17
17
  import { FullAutoSetup } from "./FullAutoSetup"
18
18
  import { MonitorDashboard } from "./MonitorDashboard"
19
19
  import { HomepageSetup } from "./HomepageSetup"
20
+ import { JellyfinSetup } from "./JellyfinSetup"
20
21
 
21
22
  export class MainMenu {
22
23
  private renderer: RenderContext
@@ -134,6 +135,10 @@ export class MainMenu {
134
135
  name: "🏠 Homepage Setup",
135
136
  description: "Generate Homepage dashboard config",
136
137
  },
138
+ {
139
+ name: "🎬 Jellyfin Setup",
140
+ description: "Run Jellyfin setup wizard via API",
141
+ },
137
142
  { name: "❌ Exit", description: "Close easiarr" },
138
143
  ],
139
144
  })
@@ -243,7 +248,18 @@ export class MainMenu {
243
248
  this.container.add(homepageSetup)
244
249
  break
245
250
  }
246
- case 12:
251
+ case 12: {
252
+ // Jellyfin Setup
253
+ this.menu.blur()
254
+ this.page.visible = false
255
+ const jellyfinSetup = new JellyfinSetup(this.renderer as CliRenderer, this.config, () => {
256
+ this.page.visible = true
257
+ this.menu.focus()
258
+ })
259
+ this.container.add(jellyfinSetup)
260
+ break
261
+ }
262
+ case 13:
247
263
  process.exit(0)
248
264
  break
249
265
  }
package/src/utils/env.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * Shared functions for reading/writing .env files
4
4
  */
5
5
 
6
- import { existsSync, readFileSync } from "node:fs"
6
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
7
7
  import { writeFile, readFile } from "node:fs/promises"
8
8
  import { networkInterfaces } from "node:os"
9
9
  import { getComposePath } from "../config/manager"
@@ -82,6 +82,22 @@ export function readEnvSync(): Record<string, string> {
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * Write to .env file synchronously, merging with existing values
87
+ * Preserves existing keys not in the updates object
88
+ */
89
+ export function writeEnvSync(updates: Record<string, string>): void {
90
+ const envPath = getEnvPath()
91
+ const current = readEnvSync()
92
+
93
+ // Merge updates into current
94
+ const merged = { ...current, ...updates }
95
+
96
+ // Write back
97
+ const content = serializeEnv(merged)
98
+ writeFileSync(envPath, content, "utf-8")
99
+ }
100
+
85
101
  /**
86
102
  * Read the .env file asynchronously
87
103
  */