@muhammedaksam/easiarr 0.8.2 → 0.8.4

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.8.2",
3
+ "version": "0.8.4",
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",
@@ -17,7 +17,7 @@ export interface StartupConfiguration {
17
17
 
18
18
  export interface StartupUser {
19
19
  Name: string
20
- Pw: string
20
+ Password: string
21
21
  }
22
22
 
23
23
  export interface StartupRemoteAccess {
@@ -66,13 +66,34 @@ export interface SystemInfo {
66
66
  StartupWizardCompleted: boolean
67
67
  }
68
68
 
69
+ // ==========================================
70
+ // User Types
71
+ // ==========================================
72
+
73
+ export interface UserPolicy {
74
+ IsAdministrator: boolean
75
+ IsHidden: boolean
76
+ IsDisabled: boolean
77
+ EnableRemoteAccess: boolean
78
+ AuthenticationProviderId?: string
79
+ PasswordResetProviderId?: string
80
+ [key: string]: unknown // Allow other properties
81
+ }
82
+
83
+ export interface UserDto {
84
+ Id: string
85
+ Name?: string
86
+ ServerId?: string
87
+ HasPassword: boolean
88
+ LastLoginDate?: string
89
+ Policy?: UserPolicy
90
+ [key: string]: unknown // Allow other properties
91
+ }
92
+
69
93
  export interface AuthResult {
70
94
  AccessToken: string
71
95
  ServerId: string
72
- User: {
73
- Id: string
74
- Name: string
75
- }
96
+ User: UserDto
76
97
  }
77
98
 
78
99
  // ==========================================
@@ -80,6 +101,27 @@ export interface AuthResult {
80
101
  // ==========================================
81
102
 
82
103
  export class JellyfinClient {
104
+ // ==========================================
105
+ // User Management
106
+ // ==========================================
107
+
108
+ /**
109
+ * Get a user's details
110
+ */
111
+ async getUser(userId: string): Promise<UserDto> {
112
+ return this.request<UserDto>(`/Users/${userId}`)
113
+ }
114
+
115
+ /**
116
+ * Update a user's policy (permissions)
117
+ */
118
+ async updateUserPolicy(userId: string, policy: Partial<UserPolicy>): Promise<void> {
119
+ await this.request(`/Users/${userId}/Policy`, {
120
+ method: "POST",
121
+ body: JSON.stringify(policy),
122
+ })
123
+ }
124
+
83
125
  private baseUrl: string
84
126
  private accessToken?: string
85
127
 
@@ -169,7 +211,7 @@ export class JellyfinClient {
169
211
  await this.getFirstUser()
170
212
 
171
213
  // Then update with our credentials
172
- const user: StartupUser = { Name: name, Pw: password }
214
+ const user: StartupUser = { Name: name, Password: password }
173
215
  await this.request("/Startup/User", {
174
216
  method: "POST",
175
217
  body: JSON.stringify(user),
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Jellyseerr API Client
3
+ * Handles setup wizard automation and service configuration
4
+ *
5
+ * Based on Jellyseerr source code analysis:
6
+ * - Auth endpoint: POST /api/v1/auth/jellyfin
7
+ * - Setup mode: requires hostname, port, serverType (2=Jellyfin, 3=Emby), useSsl
8
+ * - Login mode: only requires username and password (when server already configured)
9
+ */
10
+
11
+ import { debugLog } from "../utils/debug"
12
+
13
+ // ==========================================
14
+ // Enums (from Jellyseerr server/constants/server.ts)
15
+ // ==========================================
16
+
17
+ export enum MediaServerType {
18
+ PLEX = 1,
19
+ JELLYFIN = 2,
20
+ EMBY = 3,
21
+ NOT_CONFIGURED = 4,
22
+ }
23
+
24
+ // ==========================================
25
+ // Types
26
+ // ==========================================
27
+
28
+ export interface JellyseerrPublicSettings {
29
+ initialized: boolean
30
+ }
31
+
32
+ export interface JellyseerrMainSettings {
33
+ apiKey: string
34
+ appLanguage: string
35
+ applicationTitle: string
36
+ applicationUrl: string
37
+ mediaServerType: number
38
+ localLogin: boolean
39
+ newPlexLogin: boolean
40
+ defaultPermissions: number
41
+ }
42
+
43
+ export interface JellyseerrJellyfinSettings {
44
+ name?: string
45
+ ip?: string
46
+ hostname?: string
47
+ port?: number
48
+ useSsl?: boolean
49
+ urlBase?: string
50
+ externalHostname?: string
51
+ adminUser?: string
52
+ adminPass?: string
53
+ serverId?: string
54
+ apiKey?: string
55
+ libraries?: JellyseerrLibrary[]
56
+ }
57
+
58
+ export interface JellyseerrLibrary {
59
+ id: string
60
+ name: string
61
+ enabled: boolean
62
+ }
63
+
64
+ export interface JellyseerrUser {
65
+ id: number
66
+ email: string
67
+ username?: string
68
+ jellyfinUsername?: string
69
+ jellyfinUserId?: string
70
+ userType: number
71
+ permissions: number
72
+ avatar?: string
73
+ }
74
+
75
+ export interface JellyseerrRadarrSettings {
76
+ id?: number
77
+ name: string
78
+ hostname: string
79
+ port: number
80
+ apiKey: string
81
+ useSsl: boolean
82
+ baseUrl?: string
83
+ activeProfileId: number
84
+ activeProfileName: string
85
+ activeDirectory: string
86
+ is4k: boolean
87
+ minimumAvailability: string
88
+ isDefault: boolean
89
+ syncEnabled?: boolean
90
+ preventSearch?: boolean
91
+ externalUrl?: string
92
+ }
93
+
94
+ export interface JellyseerrSonarrSettings {
95
+ id?: number
96
+ name: string
97
+ hostname: string
98
+ port: number
99
+ apiKey: string
100
+ useSsl: boolean
101
+ baseUrl?: string
102
+ activeProfileId: number
103
+ activeProfileName: string
104
+ activeDirectory: string
105
+ activeLanguageProfileId?: number
106
+ is4k: boolean
107
+ enableSeasonFolders: boolean
108
+ isDefault: boolean
109
+ syncEnabled?: boolean
110
+ preventSearch?: boolean
111
+ externalUrl?: string
112
+ }
113
+
114
+ export interface ServiceProfile {
115
+ id: number
116
+ name: string
117
+ }
118
+
119
+ export interface ServiceTestResult {
120
+ profiles: ServiceProfile[]
121
+ rootFolders?: { id: number; path: string }[]
122
+ }
123
+
124
+ /** Auth request for initial setup (unconfigured server) */
125
+ interface JellyfinSetupAuthRequest {
126
+ username: string
127
+ password: string
128
+ hostname: string
129
+ port: number
130
+ useSsl: boolean
131
+ urlBase: string
132
+ serverType: MediaServerType
133
+ email?: string
134
+ }
135
+
136
+ /** Auth request for login (already configured server) */
137
+ interface JellyfinLoginRequest {
138
+ username: string
139
+ password: string
140
+ }
141
+
142
+ // ==========================================
143
+ // Client
144
+ // ==========================================
145
+
146
+ export class JellyseerrClient {
147
+ private baseUrl: string
148
+ private cookie?: string
149
+
150
+ constructor(host: string, port: number) {
151
+ this.baseUrl = `http://${host}:${port}`
152
+ }
153
+
154
+ private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
155
+ const url = `${this.baseUrl}/api/v1${endpoint}`
156
+ const headers: Record<string, string> = {
157
+ "Content-Type": "application/json",
158
+ ...(options.headers as Record<string, string>),
159
+ }
160
+
161
+ if (this.cookie) {
162
+ headers["Cookie"] = this.cookie
163
+ }
164
+
165
+ debugLog("Jellyseerr", `${options.method || "GET"} ${endpoint}`)
166
+
167
+ const response = await fetch(url, {
168
+ ...options,
169
+ headers,
170
+ })
171
+
172
+ // Capture session cookie from auth responses
173
+ const setCookie = response.headers.get("set-cookie")
174
+ if (setCookie) {
175
+ this.cookie = setCookie.split(";")[0]
176
+ debugLog("Jellyseerr", "Session cookie captured")
177
+ }
178
+
179
+ if (!response.ok) {
180
+ const text = await response.text()
181
+ debugLog("Jellyseerr", `Error ${response.status}: ${text}`)
182
+ throw new Error(`Jellyseerr API error: ${response.status} - ${text}`)
183
+ }
184
+
185
+ const contentType = response.headers.get("content-type")
186
+ if (contentType?.includes("application/json")) {
187
+ return response.json()
188
+ }
189
+ return {} as T
190
+ }
191
+
192
+ // ==========================================
193
+ // Health & Status
194
+ // ==========================================
195
+
196
+ async isHealthy(): Promise<boolean> {
197
+ try {
198
+ await this.request<{ version: string }>("/status")
199
+ return true
200
+ } catch {
201
+ return false
202
+ }
203
+ }
204
+
205
+ async isInitialized(): Promise<boolean> {
206
+ try {
207
+ const settings = await this.request<JellyseerrPublicSettings>("/settings/public")
208
+ return settings.initialized
209
+ } catch {
210
+ return false
211
+ }
212
+ }
213
+
214
+ // ==========================================
215
+ // Main Settings
216
+ // ==========================================
217
+
218
+ async getMainSettings(): Promise<JellyseerrMainSettings> {
219
+ return this.request<JellyseerrMainSettings>("/settings/main")
220
+ }
221
+
222
+ async updateMainSettings(settings: Partial<JellyseerrMainSettings>): Promise<JellyseerrMainSettings> {
223
+ return this.request<JellyseerrMainSettings>("/settings/main", {
224
+ method: "POST",
225
+ body: JSON.stringify(settings),
226
+ })
227
+ }
228
+
229
+ /**
230
+ * Mark the setup wizard as complete.
231
+ * Must be called after configuring all settings.
232
+ */
233
+ async initialize(): Promise<{ initialized: boolean }> {
234
+ return this.request<{ initialized: boolean }>("/settings/initialize", {
235
+ method: "POST",
236
+ })
237
+ }
238
+
239
+ // ==========================================
240
+ // Jellyfin Configuration
241
+ // ==========================================
242
+
243
+ async getJellyfinSettings(): Promise<JellyseerrJellyfinSettings> {
244
+ return this.request<JellyseerrJellyfinSettings>("/settings/jellyfin")
245
+ }
246
+
247
+ async updateJellyfinSettings(settings: Partial<JellyseerrJellyfinSettings>): Promise<JellyseerrJellyfinSettings> {
248
+ return this.request<JellyseerrJellyfinSettings>("/settings/jellyfin", {
249
+ method: "POST",
250
+ body: JSON.stringify(settings),
251
+ })
252
+ }
253
+
254
+ async syncJellyfinLibraries(): Promise<JellyseerrLibrary[]> {
255
+ return this.request<JellyseerrLibrary[]>("/settings/jellyfin/library?sync=true")
256
+ }
257
+
258
+ async enableLibraries(libraryIds: string[]): Promise<JellyseerrLibrary[]> {
259
+ const enable = libraryIds.join(",")
260
+ return this.request<JellyseerrLibrary[]>(`/settings/jellyfin/library?enable=${encodeURIComponent(enable)}`)
261
+ }
262
+
263
+ // ==========================================
264
+ // Authentication
265
+ // ==========================================
266
+
267
+ /**
268
+ * Authenticate with Jellyfin credentials.
269
+ *
270
+ * This method handles two scenarios:
271
+ * 1. Fresh setup: Sends full payload with hostname, port, serverType
272
+ * 2. Already configured: If setup payload fails, retries with just username/password
273
+ *
274
+ * @param username - Jellyfin username
275
+ * @param password - Jellyfin password
276
+ * @param hostname - Jellyfin hostname (container name or IP)
277
+ * @param port - Jellyfin port (default 8096)
278
+ * @param email - Optional email for the Jellyseerr user
279
+ */
280
+ async authenticateJellyfin(
281
+ username: string,
282
+ password: string,
283
+ hostname: string,
284
+ port: number,
285
+ email?: string
286
+ ): Promise<JellyseerrUser> {
287
+ // Attempt 1: Full setup payload (for fresh installs)
288
+ const setupPayload: JellyfinSetupAuthRequest = {
289
+ username,
290
+ password,
291
+ hostname,
292
+ port,
293
+ useSsl: false,
294
+ urlBase: "",
295
+ serverType: MediaServerType.JELLYFIN,
296
+ email: email || `${username}@local`,
297
+ }
298
+
299
+ debugLog(
300
+ "Jellyseerr",
301
+ `Auth attempt with setup payload: hostname=${hostname}, port=${port}, serverType=${MediaServerType.JELLYFIN}`
302
+ )
303
+
304
+ try {
305
+ return await this.request<JellyseerrUser>("/auth/jellyfin", {
306
+ method: "POST",
307
+ body: JSON.stringify(setupPayload),
308
+ })
309
+ } catch (err: unknown) {
310
+ const message = err instanceof Error ? err.message : String(err)
311
+
312
+ // Check if server is already configured
313
+ if (message.includes("already configured") || message.includes("hostname already configured")) {
314
+ debugLog("Jellyseerr", "Server already configured, retrying with login-only payload")
315
+
316
+ // Attempt 2: Login-only payload (server already configured)
317
+ const loginPayload: JellyfinLoginRequest = {
318
+ username,
319
+ password,
320
+ }
321
+
322
+ return this.request<JellyseerrUser>("/auth/jellyfin", {
323
+ method: "POST",
324
+ body: JSON.stringify(loginPayload),
325
+ })
326
+ }
327
+
328
+ // Re-throw other errors with more context
329
+ if (message.includes("NO_ADMIN_USER") || message.includes("NotAdmin")) {
330
+ throw new Error(
331
+ `Jellyfin user "${username}" is not an administrator. Please ensure the user has admin permissions in Jellyfin.`
332
+ )
333
+ }
334
+
335
+ if (message.includes("InvalidCredentials") || message.includes("401")) {
336
+ throw new Error(`Invalid Jellyfin credentials for user "${username}".`)
337
+ }
338
+
339
+ if (message.includes("InvalidUrl") || message.includes("INVALID_URL")) {
340
+ throw new Error(`Cannot reach Jellyfin at ${hostname}:${port}. Check the hostname and port.`)
341
+ }
342
+
343
+ throw err
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Authenticate with Plex token
349
+ */
350
+ async authenticatePlex(authToken: string): Promise<JellyseerrUser> {
351
+ return this.request<JellyseerrUser>("/auth/plex", {
352
+ method: "POST",
353
+ body: JSON.stringify({ authToken }),
354
+ })
355
+ }
356
+
357
+ // ==========================================
358
+ // Radarr Configuration
359
+ // ==========================================
360
+
361
+ async getRadarrSettings(): Promise<JellyseerrRadarrSettings[]> {
362
+ return this.request<JellyseerrRadarrSettings[]>("/settings/radarr")
363
+ }
364
+
365
+ async testRadarr(config: {
366
+ hostname: string
367
+ port: number
368
+ apiKey: string
369
+ useSsl: boolean
370
+ baseUrl?: string
371
+ }): Promise<ServiceTestResult> {
372
+ return this.request<ServiceTestResult>("/settings/radarr/test", {
373
+ method: "POST",
374
+ body: JSON.stringify(config),
375
+ })
376
+ }
377
+
378
+ async addRadarr(settings: JellyseerrRadarrSettings): Promise<JellyseerrRadarrSettings> {
379
+ return this.request<JellyseerrRadarrSettings>("/settings/radarr", {
380
+ method: "POST",
381
+ body: JSON.stringify(settings),
382
+ })
383
+ }
384
+
385
+ // ==========================================
386
+ // Sonarr Configuration
387
+ // ==========================================
388
+
389
+ async getSonarrSettings(): Promise<JellyseerrSonarrSettings[]> {
390
+ return this.request<JellyseerrSonarrSettings[]>("/settings/sonarr")
391
+ }
392
+
393
+ async testSonarr(config: {
394
+ hostname: string
395
+ port: number
396
+ apiKey: string
397
+ useSsl: boolean
398
+ baseUrl?: string
399
+ }): Promise<ServiceTestResult> {
400
+ return this.request<ServiceTestResult>("/settings/sonarr/test", {
401
+ method: "POST",
402
+ body: JSON.stringify(config),
403
+ })
404
+ }
405
+
406
+ async addSonarr(settings: JellyseerrSonarrSettings): Promise<JellyseerrSonarrSettings> {
407
+ return this.request<JellyseerrSonarrSettings>("/settings/sonarr", {
408
+ method: "POST",
409
+ body: JSON.stringify(settings),
410
+ })
411
+ }
412
+
413
+ // ==========================================
414
+ // Full Setup Wizard
415
+ // ==========================================
416
+
417
+ /**
418
+ * Run the full setup wizard for Jellyfin
419
+ * Returns the API key on success
420
+ */
421
+ async runJellyfinSetup(
422
+ jellyfinHostname: string,
423
+ port: number,
424
+ username: string,
425
+ password: string,
426
+ email?: string
427
+ ): Promise<string> {
428
+ // Step 1: Authenticate (creates first admin if none exists)
429
+ await this.authenticateJellyfin(username, password, jellyfinHostname, port, email)
430
+
431
+ // Step 2: Update Jellyfin settings with full URL
432
+ const fullUrl = `http://${jellyfinHostname}:${port}`
433
+ await this.updateJellyfinSettings({
434
+ hostname: fullUrl,
435
+ adminUser: username,
436
+ adminPass: password,
437
+ })
438
+
439
+ // Step 3: Sync libraries
440
+ const libraries = await this.syncJellyfinLibraries()
441
+
442
+ // Step 4: Enable all libraries
443
+ const libraryIds = libraries.map((lib) => lib.id)
444
+ if (libraryIds.length > 0) {
445
+ await this.enableLibraries(libraryIds)
446
+ }
447
+
448
+ // Step 5: Get API key
449
+ const mainSettings = await this.getMainSettings()
450
+ return mainSettings.apiKey
451
+ }
452
+
453
+ /**
454
+ * Configure Radarr connection with auto-detection of profiles
455
+ */
456
+ async configureRadarr(
457
+ hostname: string,
458
+ port: number,
459
+ apiKey: string,
460
+ rootFolder: string
461
+ ): Promise<JellyseerrRadarrSettings | null> {
462
+ try {
463
+ const testResult = await this.testRadarr({
464
+ hostname,
465
+ port,
466
+ apiKey,
467
+ useSsl: false,
468
+ })
469
+
470
+ if (!testResult.profiles || testResult.profiles.length === 0) {
471
+ debugLog("Jellyseerr", "No Radarr profiles found")
472
+ return null
473
+ }
474
+
475
+ const profile = testResult.profiles[0]
476
+
477
+ return await this.addRadarr({
478
+ name: "Radarr",
479
+ hostname,
480
+ port,
481
+ apiKey,
482
+ useSsl: false,
483
+ activeProfileId: profile.id,
484
+ activeProfileName: profile.name,
485
+ activeDirectory: rootFolder,
486
+ is4k: false,
487
+ minimumAvailability: "announced",
488
+ isDefault: true,
489
+ })
490
+ } catch (e) {
491
+ debugLog("Jellyseerr", `Radarr config failed: ${e}`)
492
+ return null
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Configure Sonarr connection with auto-detection of profiles
498
+ */
499
+ async configureSonarr(
500
+ hostname: string,
501
+ port: number,
502
+ apiKey: string,
503
+ rootFolder: string
504
+ ): Promise<JellyseerrSonarrSettings | null> {
505
+ try {
506
+ const testResult = await this.testSonarr({
507
+ hostname,
508
+ port,
509
+ apiKey,
510
+ useSsl: false,
511
+ })
512
+
513
+ if (!testResult.profiles || testResult.profiles.length === 0) {
514
+ debugLog("Jellyseerr", "No Sonarr profiles found")
515
+ return null
516
+ }
517
+
518
+ const profile = testResult.profiles[0]
519
+
520
+ return await this.addSonarr({
521
+ name: "Sonarr",
522
+ hostname,
523
+ port,
524
+ apiKey,
525
+ useSsl: false,
526
+ activeProfileId: profile.id,
527
+ activeProfileName: profile.name,
528
+ activeDirectory: rootFolder,
529
+ is4k: false,
530
+ enableSeasonFolders: true,
531
+ isDefault: true,
532
+ })
533
+ } catch (e) {
534
+ debugLog("Jellyseerr", `Sonarr config failed: ${e}`)
535
+ return null
536
+ }
537
+ }
538
+ }
@@ -408,6 +408,7 @@ export const APPS: Record<AppId, AppDefinition> = {
408
408
  parser: "json",
409
409
  selector: "main.apiKey",
410
410
  },
411
+ homepage: { icon: "jellyseerr.png", widget: "jellyseerr" },
411
412
  },
412
413
 
413
414
  // === DASHBOARDS ===