@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 +1 -1
- package/src/api/jellyfin-api.ts +359 -0
- package/src/apps/registry.ts +1 -1
- package/src/ui/screens/ApiKeyViewer.ts +2 -2
- package/src/ui/screens/AppConfigurator.ts +3 -3
- package/src/ui/screens/FullAutoSetup.ts +46 -1
- package/src/ui/screens/JellyfinSetup.ts +471 -0
- package/src/ui/screens/MainMenu.ts +17 -1
- package/src/utils/env.ts +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|
package/src/apps/registry.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
87
|
+
this.globalPassword = env.PASSWORD_GLOBAL || "Ch4ng3m3!1234securityReasons"
|
|
88
88
|
if (env.PASSWORD_QBITTORRENT) this.qbPass = env.PASSWORD_QBITTORRENT
|
|
89
|
-
if (env.
|
|
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.
|
|
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
|
*/
|