@muhammedaksam/easiarr 1.0.0 → 1.1.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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Plex API Client
3
+ * Handles Plex Media Server auto-setup including server claiming and library creation
4
+ */
5
+
6
+ import { debugLog } from "../utils/debug"
7
+ import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
8
+
9
+ // Plex client identifier for API requests
10
+ const PLEX_CLIENT_ID = "easiarr"
11
+ const PLEX_PRODUCT = "Easiarr"
12
+ const PLEX_VERSION = "1.0.0"
13
+ const PLEX_DEVICE = "Server"
14
+
15
+ interface PlexLibrarySection {
16
+ key: string
17
+ type: string
18
+ title: string
19
+ agent: string
20
+ scanner: string
21
+ language: string
22
+ Location: { id: number; path: string }[]
23
+ }
24
+
25
+ interface PlexServerInfo {
26
+ machineIdentifier: string
27
+ version: string
28
+ claimed: boolean
29
+ }
30
+
31
+ export class PlexApiClient implements IAutoSetupClient {
32
+ private host: string
33
+ private port: number
34
+ private token?: string
35
+
36
+ constructor(host: string, port: number = 32400, token?: string) {
37
+ this.host = host
38
+ this.port = port
39
+ this.token = token
40
+ }
41
+
42
+ /**
43
+ * Set the Plex token for authenticated requests
44
+ */
45
+ setToken(token: string): void {
46
+ this.token = token
47
+ }
48
+
49
+ /**
50
+ * Get base URL for local Plex server
51
+ */
52
+ private get baseUrl(): string {
53
+ return `http://${this.host}:${this.port}`
54
+ }
55
+
56
+ /**
57
+ * Common headers for Plex API requests
58
+ */
59
+ private getHeaders(): Record<string, string> {
60
+ const headers: Record<string, string> = {
61
+ Accept: "application/json",
62
+ "X-Plex-Client-Identifier": PLEX_CLIENT_ID,
63
+ "X-Plex-Product": PLEX_PRODUCT,
64
+ "X-Plex-Version": PLEX_VERSION,
65
+ "X-Plex-Device": PLEX_DEVICE,
66
+ }
67
+ if (this.token) {
68
+ headers["X-Plex-Token"] = this.token
69
+ }
70
+ return headers
71
+ }
72
+
73
+ /**
74
+ * Check if Plex server is reachable
75
+ */
76
+ async isHealthy(): Promise<boolean> {
77
+ try {
78
+ const response = await fetch(`${this.baseUrl}/identity`, {
79
+ method: "GET",
80
+ headers: this.getHeaders(),
81
+ })
82
+ debugLog("PlexApi", `Health check: ${response.status}`)
83
+ return response.ok
84
+ } catch (error) {
85
+ debugLog("PlexApi", `Health check failed: ${error}`)
86
+ return false
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Check if server is already claimed (initialized)
92
+ */
93
+ async isInitialized(): Promise<boolean> {
94
+ try {
95
+ const info = await this.getServerInfo()
96
+ return info.claimed
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Get server information including claim status
104
+ */
105
+ async getServerInfo(): Promise<PlexServerInfo> {
106
+ const response = await fetch(`${this.baseUrl}/`, {
107
+ method: "GET",
108
+ headers: this.getHeaders(),
109
+ })
110
+
111
+ if (!response.ok) {
112
+ throw new Error(`Failed to get server info: ${response.status}`)
113
+ }
114
+
115
+ const data = await response.json()
116
+ const container = data.MediaContainer
117
+
118
+ return {
119
+ machineIdentifier: container.machineIdentifier,
120
+ version: container.version,
121
+ claimed: container.myPlex === true || !!container.myPlexUsername,
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Claim the server using a claim token from plex.tv/claim
127
+ * The claim token has a 4-minute expiry
128
+ */
129
+ async claimServer(claimToken: string): Promise<void> {
130
+ debugLog("PlexApi", "Claiming server with token...")
131
+
132
+ // Claim token should start with "claim-"
133
+ const token = claimToken.startsWith("claim-") ? claimToken : `claim-${claimToken}`
134
+
135
+ const response = await fetch(`${this.baseUrl}/myplex/claim?token=${token}`, {
136
+ method: "POST",
137
+ headers: this.getHeaders(),
138
+ })
139
+
140
+ if (!response.ok) {
141
+ const text = await response.text()
142
+ throw new Error(`Failed to claim server: ${response.status} - ${text}`)
143
+ }
144
+
145
+ debugLog("PlexApi", "Server claimed successfully")
146
+ }
147
+
148
+ /**
149
+ * Get list of library sections
150
+ */
151
+ async getLibrarySections(): Promise<PlexLibrarySection[]> {
152
+ const response = await fetch(`${this.baseUrl}/library/sections`, {
153
+ method: "GET",
154
+ headers: this.getHeaders(),
155
+ })
156
+
157
+ if (!response.ok) {
158
+ throw new Error(`Failed to get library sections: ${response.status}`)
159
+ }
160
+
161
+ const data = await response.json()
162
+ return data.MediaContainer?.Directory || []
163
+ }
164
+
165
+ /**
166
+ * Create a library section
167
+ * @param name - Display name for the library
168
+ * @param type - Library type: movie, show, artist (music)
169
+ * @param path - Path to media files (inside container)
170
+ * @param language - Language code (default: en-US)
171
+ */
172
+ async createLibrary(
173
+ name: string,
174
+ type: "movie" | "show" | "artist",
175
+ path: string,
176
+ language: string = "en-US"
177
+ ): Promise<void> {
178
+ debugLog("PlexApi", `Creating library: ${name} (${type}) at ${path}`)
179
+
180
+ // Map type to agent and scanner
181
+ const agents: Record<string, { agent: string; scanner: string }> = {
182
+ movie: {
183
+ agent: "tv.plex.agents.movie",
184
+ scanner: "Plex Movie",
185
+ },
186
+ show: {
187
+ agent: "tv.plex.agents.series",
188
+ scanner: "Plex TV Series",
189
+ },
190
+ artist: {
191
+ agent: "tv.plex.agents.music",
192
+ scanner: "Plex Music",
193
+ },
194
+ }
195
+
196
+ const config = agents[type]
197
+ if (!config) {
198
+ throw new Error(`Unknown library type: ${type}`)
199
+ }
200
+
201
+ const params = new URLSearchParams({
202
+ name,
203
+ type,
204
+ agent: config.agent,
205
+ scanner: config.scanner,
206
+ language,
207
+ "location[0]": path,
208
+ })
209
+
210
+ const response = await fetch(`${this.baseUrl}/library/sections?${params.toString()}`, {
211
+ method: "POST",
212
+ headers: this.getHeaders(),
213
+ })
214
+
215
+ if (!response.ok) {
216
+ const text = await response.text()
217
+ throw new Error(`Failed to create library: ${response.status} - ${text}`)
218
+ }
219
+
220
+ debugLog("PlexApi", `Library "${name}" created successfully`)
221
+ }
222
+
223
+ /**
224
+ * Check if a library with the given path already exists
225
+ */
226
+ async libraryExistsForPath(path: string): Promise<boolean> {
227
+ const sections = await this.getLibrarySections()
228
+ return sections.some((section) => section.Location?.some((loc) => loc.path === path))
229
+ }
230
+
231
+ /**
232
+ * Trigger a library scan for all sections
233
+ */
234
+ async scanAllLibraries(): Promise<void> {
235
+ const sections = await this.getLibrarySections()
236
+ for (const section of sections) {
237
+ await fetch(`${this.baseUrl}/library/sections/${section.key}/refresh`, {
238
+ method: "GET",
239
+ headers: this.getHeaders(),
240
+ })
241
+ }
242
+ debugLog("PlexApi", "Triggered scan for all libraries")
243
+ }
244
+
245
+ /**
246
+ * Run the auto-setup process for Plex
247
+ */
248
+ async setup(options: AutoSetupOptions): Promise<AutoSetupResult> {
249
+ const { env, plexToken } = options
250
+
251
+ // Check if server is reachable
252
+ const healthy = await this.isHealthy()
253
+ if (!healthy) {
254
+ return { success: false, message: "Plex server not reachable" }
255
+ }
256
+
257
+ // Check if already claimed
258
+ try {
259
+ const serverInfo = await this.getServerInfo()
260
+ if (serverInfo.claimed) {
261
+ return {
262
+ success: true,
263
+ message: "Already claimed",
264
+ data: {
265
+ machineIdentifier: serverInfo.machineIdentifier,
266
+ version: serverInfo.version,
267
+ },
268
+ }
269
+ }
270
+ } catch {
271
+ // Continue with setup if we can't get server info
272
+ }
273
+
274
+ // Get claim token from environment or options
275
+ const claimToken = env["PLEX_CLAIM"]
276
+ if (!claimToken) {
277
+ return {
278
+ success: false,
279
+ message: "No PLEX_CLAIM token. Get one from https://plex.tv/claim (4-min expiry)",
280
+ }
281
+ }
282
+
283
+ // Store plexToken for future authenticated requests
284
+ if (plexToken) {
285
+ this.setToken(plexToken)
286
+ }
287
+
288
+ try {
289
+ // Claim the server
290
+ await this.claimServer(claimToken)
291
+
292
+ // Get server info after claiming
293
+ const serverInfo = await this.getServerInfo()
294
+
295
+ // Create default libraries if paths exist
296
+ const libraries = [
297
+ { name: "Movies", type: "movie" as const, path: "/data/media/movies" },
298
+ { name: "TV Shows", type: "show" as const, path: "/data/media/tv" },
299
+ { name: "Music", type: "artist" as const, path: "/data/media/music" },
300
+ ]
301
+
302
+ let librariesCreated = 0
303
+ for (const lib of libraries) {
304
+ const exists = await this.libraryExistsForPath(lib.path)
305
+ if (!exists) {
306
+ try {
307
+ await this.createLibrary(lib.name, lib.type, lib.path)
308
+ librariesCreated++
309
+ } catch (e) {
310
+ // Library creation may fail if path doesn't exist - that's OK
311
+ debugLog("PlexApi", `Could not create library ${lib.name}: ${e}`)
312
+ }
313
+ }
314
+ }
315
+
316
+ return {
317
+ success: true,
318
+ message: `Server claimed, ${librariesCreated} libraries configured`,
319
+ data: {
320
+ machineIdentifier: serverInfo.machineIdentifier,
321
+ version: serverInfo.version,
322
+ librariesCreated,
323
+ },
324
+ }
325
+ } catch (error) {
326
+ return { success: false, message: `${error}` }
327
+ }
328
+ }
329
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { debugLog } from "../utils/debug"
7
7
  import { ensureMinPasswordLength } from "../utils/password"
8
+ import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
8
9
 
9
10
  // Portainer requires minimum 12 character password
10
11
  export const PORTAINER_MIN_PASSWORD_LENGTH = 12
@@ -61,7 +62,7 @@ export interface PortainerApiKeyResponse {
61
62
  /**
62
63
  * Portainer API Client
63
64
  */
64
- export class PortainerApiClient {
65
+ export class PortainerApiClient implements IAutoSetupClient {
65
66
  private baseUrl: string
66
67
  private jwtToken: string | null = null
67
68
  private apiKey: string | null = null
@@ -346,6 +347,83 @@ export class PortainerApiClient {
346
347
  `/endpoints/${endpointId}/docker/containers/${containerId}/stats?stream=false`
347
348
  )
348
349
  }
350
+
351
+ /**
352
+ * Check if already configured (has admin user)
353
+ */
354
+ async isInitialized(): Promise<boolean> {
355
+ return !(await this.needsInitialization())
356
+ }
357
+
358
+ /**
359
+ * Run the auto-setup process for Portainer
360
+ */
361
+ async setup(options: AutoSetupOptions): Promise<AutoSetupResult> {
362
+ const { username, password } = options
363
+
364
+ try {
365
+ // Check if reachable
366
+ const healthy = await this.isHealthy()
367
+ if (!healthy) {
368
+ return { success: false, message: "Portainer not reachable" }
369
+ }
370
+
371
+ // Check if needs initialization
372
+ const needsInit = await this.needsInitialization()
373
+
374
+ let actualPassword = ensureMinPasswordLength(password, PORTAINER_MIN_PASSWORD_LENGTH)
375
+ let passwordPadded = actualPassword !== password
376
+ let apiKey: string | undefined
377
+
378
+ if (needsInit) {
379
+ // Initialize admin user
380
+ const result = await this.initializeAdmin(username, password)
381
+ if (result) {
382
+ actualPassword = result.actualPassword
383
+ passwordPadded = result.passwordWasPadded
384
+ }
385
+
386
+ // Generate API key
387
+ try {
388
+ apiKey = await this.generateApiKey(actualPassword)
389
+ } catch {
390
+ // API key generation may fail, that's OK
391
+ }
392
+ } else {
393
+ // Login with existing credentials
394
+ try {
395
+ await this.login(username, actualPassword)
396
+ } catch {
397
+ return { success: false, message: "Login failed - check credentials" }
398
+ }
399
+ }
400
+
401
+ // Get environment ID
402
+ const envId = await this.getLocalEnvironmentId()
403
+
404
+ return {
405
+ success: true,
406
+ message: needsInit
407
+ ? passwordPadded
408
+ ? `Admin created (password padded to ${PORTAINER_MIN_PASSWORD_LENGTH} chars)`
409
+ : "Admin created"
410
+ : "Logged in",
411
+ data: {
412
+ adminCreated: needsInit,
413
+ passwordPadded,
414
+ apiKey,
415
+ environmentId: envId,
416
+ },
417
+ envUpdates: {
418
+ ...(apiKey ? { API_KEY_PORTAINER: apiKey } : {}),
419
+ ...(envId ? { PORTAINER_ENV: String(envId) } : {}),
420
+ ...(passwordPadded ? { PASSWORD_PORTAINER: actualPassword } : {}),
421
+ },
422
+ }
423
+ } catch (error) {
424
+ return { success: false, message: `${error}` }
425
+ }
426
+ }
349
427
  }
350
428
 
351
429
  // ==========================================
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { debugLog } from "../utils/debug"
7
+ import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
7
8
 
8
9
  export interface IndexerProxy {
9
10
  id?: number
@@ -67,7 +68,7 @@ export interface Application {
67
68
 
68
69
  export type ArrAppType = "Radarr" | "Sonarr" | "Lidarr" | "Readarr" | "Whisparr" | "Mylar"
69
70
 
70
- export class ProwlarrClient {
71
+ export class ProwlarrClient implements IAutoSetupClient {
71
72
  private baseUrl: string
72
73
  private apiKey: string
73
74
 
@@ -364,6 +365,48 @@ export class ProwlarrClient {
364
365
 
365
366
  return this.addApplication(appType, appType, prowlarrUrl, appUrl, apiKey, "fullSync", syncCategories)
366
367
  }
368
+
369
+ /**
370
+ * Check if already configured (has any indexers)
371
+ */
372
+ async isInitialized(): Promise<boolean> {
373
+ try {
374
+ const indexers = await this.getIndexers()
375
+ return indexers.length > 0
376
+ } catch {
377
+ return false
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Run the auto-setup process for Prowlarr
383
+ */
384
+ async setup(_options: AutoSetupOptions): Promise<AutoSetupResult> {
385
+ try {
386
+ // Check if reachable
387
+ const healthy = await this.isHealthy()
388
+ if (!healthy) {
389
+ return { success: false, message: "Prowlarr not reachable" }
390
+ }
391
+
392
+ // Check current state
393
+ const indexers = await this.getIndexers()
394
+ const apps = await this.getApplications()
395
+ const proxies = await this.getIndexerProxies()
396
+
397
+ return {
398
+ success: true,
399
+ message: indexers.length > 0 ? "Configured" : "Ready for indexer setup",
400
+ data: {
401
+ indexerCount: indexers.length,
402
+ appCount: apps.length,
403
+ proxyCount: proxies.length,
404
+ },
405
+ }
406
+ } catch (error) {
407
+ return { success: false, message: `${error}` }
408
+ }
409
+ }
367
410
  }
368
411
 
369
412
  export const PROWLARR_CATEGORIES = [
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { debugLog } from "../utils/debug"
8
+ import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
8
9
 
9
10
  export interface QBittorrentPreferences {
10
11
  save_path?: string
@@ -20,7 +21,7 @@ export interface QBittorrentCategory {
20
21
  savePath: string
21
22
  }
22
23
 
23
- export class QBittorrentClient {
24
+ export class QBittorrentClient implements IAutoSetupClient {
24
25
  private baseUrl: string
25
26
  private username: string
26
27
  private password: string
@@ -222,4 +223,59 @@ export class QBittorrentClient {
222
223
  }
223
224
  debugLog("qBittorrent", "TRaSH configuration complete")
224
225
  }
226
+
227
+ /**
228
+ * Check if qBittorrent is reachable
229
+ */
230
+ async isHealthy(): Promise<boolean> {
231
+ try {
232
+ const response = await fetch(`${this.baseUrl}/api/v2/app/version`)
233
+ return response.ok || response.status === 403 // 403 means running but not logged in
234
+ } catch {
235
+ return false
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Check if already configured (can login)
241
+ */
242
+ async isInitialized(): Promise<boolean> {
243
+ // qBittorrent is always "initialized" - the question is whether we can login
244
+ return this.isConnected()
245
+ }
246
+
247
+ /**
248
+ * Run the auto-setup process for qBittorrent
249
+ */
250
+ async setup(options: AutoSetupOptions): Promise<AutoSetupResult> {
251
+ const { username, password } = options
252
+
253
+ try {
254
+ // Check if reachable
255
+ const healthy = await this.isHealthy()
256
+ if (!healthy) {
257
+ return { success: false, message: "qBittorrent not reachable" }
258
+ }
259
+
260
+ // Update credentials and try to login
261
+ this.username = username
262
+ this.password = password
263
+
264
+ const loggedIn = await this.login()
265
+ if (!loggedIn) {
266
+ return { success: false, message: "Login failed - check credentials" }
267
+ }
268
+
269
+ // Configure TRaSH-compliant settings
270
+ await this.configureTRaSHCompliant([], { user: username, pass: password })
271
+
272
+ return {
273
+ success: true,
274
+ message: "Configured with TRaSH-compliant settings",
275
+ data: { trashCompliant: true },
276
+ }
277
+ } catch (error) {
278
+ return { success: false, message: `${error}` }
279
+ }
280
+ }
225
281
  }