@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,277 @@
1
+ /**
2
+ * Tautulli API Client
3
+ * Handles Tautulli auto-setup for Plex monitoring
4
+ * Note: Initial Plex connection requires web wizard, but API key can be retrieved automatically
5
+ */
6
+
7
+ import { debugLog } from "../utils/debug"
8
+ import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
9
+
10
+ interface TautulliServerInfo {
11
+ pms_identifier?: string
12
+ pms_ip?: string
13
+ pms_is_remote?: number
14
+ pms_name?: string
15
+ pms_platform?: string
16
+ pms_plexpass?: number
17
+ pms_port?: number
18
+ pms_ssl?: number
19
+ pms_url?: string
20
+ pms_url_manual?: number
21
+ pms_version?: string
22
+ }
23
+
24
+ interface TautulliApiResponse<T = unknown> {
25
+ response: {
26
+ result: "success" | "error"
27
+ message?: string
28
+ data: T
29
+ }
30
+ }
31
+
32
+ export class TautulliClient implements IAutoSetupClient {
33
+ private host: string
34
+ private port: number
35
+ private apiKey?: string
36
+
37
+ constructor(host: string, port: number = 8181, apiKey?: string) {
38
+ this.host = host
39
+ this.port = port
40
+ this.apiKey = apiKey
41
+ }
42
+
43
+ /**
44
+ * Get base URL for Tautulli
45
+ */
46
+ private get baseUrl(): string {
47
+ return `http://${this.host}:${this.port}`
48
+ }
49
+
50
+ /**
51
+ * Set API key for authenticated requests
52
+ */
53
+ setApiKey(apiKey: string): void {
54
+ this.apiKey = apiKey
55
+ }
56
+
57
+ /**
58
+ * Build API URL with command and optional params
59
+ */
60
+ private buildApiUrl(cmd: string, params: Record<string, string> = {}): string {
61
+ const url = new URL(`${this.baseUrl}/api/v2`)
62
+ url.searchParams.set("cmd", cmd)
63
+ if (this.apiKey) {
64
+ url.searchParams.set("apikey", this.apiKey)
65
+ }
66
+ for (const [key, value] of Object.entries(params)) {
67
+ url.searchParams.set(key, value)
68
+ }
69
+ return url.toString()
70
+ }
71
+
72
+ /**
73
+ * Check if Tautulli is reachable
74
+ */
75
+ async isHealthy(): Promise<boolean> {
76
+ try {
77
+ // Tautulli returns 200 OK even without API key for basic requests
78
+ const response = await fetch(`${this.baseUrl}/status`, {
79
+ method: "GET",
80
+ })
81
+ debugLog("TautulliApi", `Health check: ${response.status}`)
82
+ return response.ok
83
+ } catch (error) {
84
+ debugLog("TautulliApi", `Health check failed: ${error}`)
85
+ return false
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Check if Tautulli has Plex connection configured
91
+ */
92
+ async isInitialized(): Promise<boolean> {
93
+ if (!this.apiKey) return false
94
+
95
+ try {
96
+ const serverInfo = await this.getServerInfo()
97
+ // If we have PMS identifier, Plex is connected
98
+ return !!serverInfo?.pms_identifier
99
+ } catch {
100
+ return false
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get or create API key
106
+ * Works without authentication on first run!
107
+ */
108
+ async getApiKey(username?: string, password?: string): Promise<string | null> {
109
+ debugLog("TautulliApi", "Getting/creating API key...")
110
+
111
+ try {
112
+ const url = new URL(`${this.baseUrl}/api/v2`)
113
+ url.searchParams.set("cmd", "get_apikey")
114
+ if (username) url.searchParams.set("username", username)
115
+ if (password) url.searchParams.set("password", password)
116
+
117
+ const response = await fetch(url.toString(), { method: "GET" })
118
+
119
+ if (response.ok) {
120
+ const data = (await response.json()) as TautulliApiResponse<string>
121
+ if (data.response?.result === "success" && data.response.data) {
122
+ const apiKey = data.response.data
123
+ this.apiKey = apiKey
124
+ debugLog("TautulliApi", "API key obtained successfully")
125
+ return apiKey
126
+ }
127
+ }
128
+
129
+ const text = await response.text()
130
+ debugLog("TautulliApi", `Failed to get API key: ${response.status} - ${text}`)
131
+ } catch (error) {
132
+ debugLog("TautulliApi", `Error getting API key: ${error}`)
133
+ }
134
+ return null
135
+ }
136
+
137
+ /**
138
+ * Get server info (requires API key)
139
+ */
140
+ async getServerInfo(): Promise<TautulliServerInfo | null> {
141
+ if (!this.apiKey) return null
142
+
143
+ try {
144
+ const response = await fetch(this.buildApiUrl("get_server_info"), {
145
+ method: "GET",
146
+ })
147
+
148
+ if (response.ok) {
149
+ const data = (await response.json()) as TautulliApiResponse<TautulliServerInfo>
150
+ if (data.response?.result === "success") {
151
+ return data.response.data
152
+ }
153
+ }
154
+ } catch {
155
+ // Ignore
156
+ }
157
+ return null
158
+ }
159
+
160
+ /**
161
+ * Get Plex Media Server info (requires API key)
162
+ */
163
+ async getPlexServerInfo(): Promise<Record<string, unknown> | null> {
164
+ if (!this.apiKey) return null
165
+
166
+ try {
167
+ const response = await fetch(this.buildApiUrl("get_server_info"), {
168
+ method: "GET",
169
+ })
170
+
171
+ if (response.ok) {
172
+ const data = (await response.json()) as TautulliApiResponse<Record<string, unknown>>
173
+ if (data.response?.result === "success") {
174
+ return data.response.data
175
+ }
176
+ }
177
+ } catch {
178
+ // Ignore
179
+ }
180
+ return null
181
+ }
182
+
183
+ /**
184
+ * Check server connection status
185
+ */
186
+ async serverStatus(): Promise<boolean> {
187
+ if (!this.apiKey) return false
188
+
189
+ try {
190
+ const response = await fetch(this.buildApiUrl("server_status"), {
191
+ method: "GET",
192
+ })
193
+
194
+ if (response.ok) {
195
+ const data = (await response.json()) as TautulliApiResponse<{ connected: boolean }>
196
+ return data.response?.result === "success" && data.response.data?.connected === true
197
+ }
198
+ } catch {
199
+ // Ignore
200
+ }
201
+ return false
202
+ }
203
+
204
+ /**
205
+ * Get API key from settings (if accessible)
206
+ */
207
+ async getSettings(): Promise<Record<string, unknown> | null> {
208
+ if (!this.apiKey) return null
209
+
210
+ try {
211
+ const response = await fetch(this.buildApiUrl("get_settings"), {
212
+ method: "GET",
213
+ })
214
+
215
+ if (response.ok) {
216
+ const data = (await response.json()) as TautulliApiResponse<Record<string, unknown>>
217
+ if (data.response?.result === "success") {
218
+ return data.response.data
219
+ }
220
+ }
221
+ } catch {
222
+ // Ignore
223
+ }
224
+ return null
225
+ }
226
+
227
+ /**
228
+ * Run the auto-setup process for Tautulli
229
+ * Gets API key automatically, but Plex connection requires manual wizard
230
+ */
231
+ async setup(options: AutoSetupOptions): Promise<AutoSetupResult> {
232
+ try {
233
+ // Check if reachable
234
+ const healthy = await this.isHealthy()
235
+ if (!healthy) {
236
+ return { success: false, message: "Tautulli not reachable" }
237
+ }
238
+
239
+ // Step 1: Get or create API key (works without auth initially)
240
+ debugLog("TautulliApi", "Step 1: Getting API key...")
241
+ let apiKey: string | undefined = this.apiKey
242
+ if (!apiKey) {
243
+ const newKey = await this.getApiKey(options.username, options.password)
244
+ if (!newKey) {
245
+ return { success: false, message: "Failed to get API key" }
246
+ }
247
+ apiKey = newKey
248
+ }
249
+
250
+ // Step 2: Check if Plex is already connected
251
+ debugLog("TautulliApi", "Step 2: Checking Plex connection...")
252
+ const serverInfo = await this.getServerInfo()
253
+ const plexConnected = !!serverInfo?.pms_identifier
254
+
255
+ if (plexConnected) {
256
+ debugLog("TautulliApi", `Plex connected: ${serverInfo?.pms_name}`)
257
+ return {
258
+ success: true,
259
+ message: `Connected to Plex: ${serverInfo?.pms_name}`,
260
+ data: { apiKey },
261
+ envUpdates: { API_KEY_TAUTULLI: apiKey },
262
+ }
263
+ }
264
+
265
+ // Plex not connected - requires manual wizard
266
+ // But we still got the API key which is useful
267
+ return {
268
+ success: true,
269
+ message: "API key obtained. Complete Plex connection via web wizard.",
270
+ data: { apiKey, requiresWizard: true },
271
+ envUpdates: { API_KEY_TAUTULLI: apiKey },
272
+ }
273
+ } catch (error) {
274
+ return { success: false, message: `${error}` }
275
+ }
276
+ }
277
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Uptime Kuma API Client
3
+ * Handles Uptime Kuma auto-setup via Socket.IO including user creation and monitor management
4
+ */
5
+
6
+ import { io, Socket } from "socket.io-client"
7
+ import { debugLog } from "../utils/debug"
8
+ import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
9
+ import type { AppConfig } from "../config/schema"
10
+ import { getApp } from "../apps/registry"
11
+
12
+ interface MonitorConfig {
13
+ type: "http" | "port" | "ping" | "docker"
14
+ name: string
15
+ url?: string
16
+ hostname?: string
17
+ port?: number
18
+ interval: number
19
+ timeout?: number
20
+ maxretries?: number
21
+ active?: boolean
22
+ docker_container?: string
23
+ docker_host?: number
24
+ }
25
+
26
+ interface UptimeKumaResponse {
27
+ ok: boolean
28
+ msg?: string
29
+ monitorID?: number
30
+ token?: string
31
+ }
32
+
33
+ export class UptimeKumaClient implements IAutoSetupClient {
34
+ private host: string
35
+ private port: number
36
+ private socket: Socket | null = null
37
+ private authenticated = false
38
+
39
+ constructor(host: string, port: number = 3001) {
40
+ this.host = host
41
+ this.port = port
42
+ }
43
+
44
+ /**
45
+ * Get base URL for Uptime Kuma
46
+ */
47
+ private get baseUrl(): string {
48
+ return `http://${this.host}:${this.port}`
49
+ }
50
+
51
+ /**
52
+ * Connect to Socket.IO server
53
+ */
54
+ private async connect(): Promise<void> {
55
+ if (this.socket?.connected) return
56
+
57
+ return new Promise((resolve, reject) => {
58
+ const timeout = setTimeout(() => {
59
+ reject(new Error("Connection timeout"))
60
+ }, 10000)
61
+
62
+ this.socket = io(this.baseUrl, {
63
+ transports: ["websocket"],
64
+ reconnection: false,
65
+ })
66
+
67
+ this.socket.on("connect", () => {
68
+ clearTimeout(timeout)
69
+ debugLog("UptimeKumaApi", "Connected to Socket.IO")
70
+ resolve()
71
+ })
72
+
73
+ this.socket.on("connect_error", (error) => {
74
+ clearTimeout(timeout)
75
+ debugLog("UptimeKumaApi", `Connection error: ${error}`)
76
+ reject(error)
77
+ })
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Disconnect from Socket.IO server
83
+ */
84
+ disconnect(): void {
85
+ if (this.socket) {
86
+ this.socket.disconnect()
87
+ this.socket = null
88
+ this.authenticated = false
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Emit a Socket.IO event and wait for callback response
94
+ */
95
+ private emit<T>(event: string, ...args: unknown[]): Promise<T> {
96
+ return new Promise((resolve, reject) => {
97
+ if (!this.socket) {
98
+ reject(new Error("Not connected"))
99
+ return
100
+ }
101
+
102
+ const timeout = setTimeout(() => {
103
+ reject(new Error(`Timeout waiting for ${event} response`))
104
+ }, 15000)
105
+
106
+ this.socket.emit(event, ...args, (response: T) => {
107
+ clearTimeout(timeout)
108
+ resolve(response)
109
+ })
110
+ })
111
+ }
112
+
113
+ /**
114
+ * Check if Uptime Kuma is reachable
115
+ */
116
+ async isHealthy(): Promise<boolean> {
117
+ try {
118
+ const response = await fetch(`${this.baseUrl}/api/status-page/heartbeat/main`, {
119
+ method: "GET",
120
+ })
121
+ // Even 404 means server is running
122
+ debugLog("UptimeKumaApi", `Health check: ${response.status}`)
123
+ return response.status !== 502 && response.status !== 503
124
+ } catch (error) {
125
+ // Try simple connection
126
+ try {
127
+ const response = await fetch(this.baseUrl)
128
+ return response.ok || response.status === 404
129
+ } catch {
130
+ debugLog("UptimeKumaApi", `Health check failed: ${error}`)
131
+ return false
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Check if already initialized (has users)
138
+ */
139
+ async isInitialized(): Promise<boolean> {
140
+ try {
141
+ await this.connect()
142
+ // Try to get info - if it needs setup, needSetup will be true
143
+ const response = await this.emit<{ needSetup: boolean }>("needSetup")
144
+ this.disconnect()
145
+ return !response.needSetup
146
+ } catch {
147
+ this.disconnect()
148
+ return true // Assume initialized if we can't check
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Setup initial admin user
154
+ */
155
+ async setupAdmin(username: string, password: string): Promise<UptimeKumaResponse> {
156
+ await this.connect()
157
+
158
+ const response = await this.emit<UptimeKumaResponse>("setup", {
159
+ username,
160
+ password,
161
+ })
162
+
163
+ if (response.ok) {
164
+ this.authenticated = true
165
+ debugLog("UptimeKumaApi", "Admin user created")
166
+ }
167
+
168
+ return response
169
+ }
170
+
171
+ /**
172
+ * Login with username and password
173
+ */
174
+ async login(username: string, password: string): Promise<boolean> {
175
+ await this.connect()
176
+
177
+ const response = await this.emit<UptimeKumaResponse>("login", {
178
+ username,
179
+ password,
180
+ token: "",
181
+ })
182
+
183
+ if (response.ok) {
184
+ this.authenticated = true
185
+ debugLog("UptimeKumaApi", "Logged in successfully")
186
+ }
187
+
188
+ return response.ok
189
+ }
190
+
191
+ /**
192
+ * Add a monitor
193
+ */
194
+ async addMonitor(config: MonitorConfig): Promise<number | null> {
195
+ if (!this.authenticated) {
196
+ throw new Error("Not authenticated")
197
+ }
198
+
199
+ const payload = {
200
+ type: config.type,
201
+ name: config.name,
202
+ url: config.url,
203
+ hostname: config.hostname,
204
+ port: config.port,
205
+ interval: config.interval || 60,
206
+ timeout: config.timeout || 30,
207
+ maxretries: config.maxretries || 3,
208
+ active: config.active ?? true,
209
+ docker_container: config.docker_container,
210
+ docker_host: config.docker_host,
211
+ accepted_statuscodes: ["200-299"],
212
+ }
213
+
214
+ const response = await this.emit<UptimeKumaResponse>("add", payload)
215
+
216
+ if (response.ok && response.monitorID) {
217
+ debugLog("UptimeKumaApi", `Monitor "${config.name}" created with ID ${response.monitorID}`)
218
+ return response.monitorID
219
+ }
220
+
221
+ debugLog("UptimeKumaApi", `Failed to create monitor: ${response.msg}`)
222
+ return null
223
+ }
224
+
225
+ /**
226
+ * Get list of all monitors
227
+ */
228
+ async getMonitors(): Promise<Record<string, unknown>[]> {
229
+ if (!this.authenticated) {
230
+ throw new Error("Not authenticated")
231
+ }
232
+
233
+ return new Promise((resolve) => {
234
+ if (!this.socket) {
235
+ resolve([])
236
+ return
237
+ }
238
+
239
+ // Uptime Kuma sends monitor list via 'monitorList' event
240
+ const timeout = setTimeout(() => resolve([]), 5000)
241
+
242
+ this.socket.once("monitorList", (data: Record<string, Record<string, unknown>>) => {
243
+ clearTimeout(timeout)
244
+ resolve(Object.values(data) as Record<string, unknown>[])
245
+ })
246
+
247
+ // Request monitor list
248
+ this.socket.emit("getMonitorList")
249
+ })
250
+ }
251
+
252
+ /**
253
+ * Auto-add monitors for enabled easiarr apps
254
+ */
255
+ async setupEasiarrMonitors(apps: AppConfig[]): Promise<number> {
256
+ let addedCount = 0
257
+
258
+ // Get existing monitors to avoid duplicates
259
+ const existingMonitors = await this.getMonitors()
260
+ const existingNames = new Set(existingMonitors.map((m) => m.name as string))
261
+
262
+ for (const appConfig of apps) {
263
+ if (!appConfig.enabled) continue
264
+
265
+ const appDef = getApp(appConfig.id)
266
+ if (!appDef) continue
267
+
268
+ // Skip apps without web UI
269
+ if (appDef.defaultPort === 0) continue
270
+
271
+ const monitorName = `Easiarr - ${appDef.name}`
272
+
273
+ // Skip if already exists
274
+ if (existingNames.has(monitorName)) {
275
+ debugLog("UptimeKumaApi", `Monitor "${monitorName}" already exists, skipping`)
276
+ continue
277
+ }
278
+
279
+ const port = appConfig.port || appDef.defaultPort
280
+ const internalPort = appDef.internalPort || port
281
+
282
+ // Create HTTP monitor for web UIs
283
+ const monitorId = await this.addMonitor({
284
+ type: "http",
285
+ name: monitorName,
286
+ url: `http://${appConfig.id}:${internalPort}`,
287
+ interval: 60,
288
+ timeout: 30,
289
+ maxretries: 2,
290
+ })
291
+
292
+ if (monitorId) {
293
+ addedCount++
294
+ }
295
+ }
296
+
297
+ return addedCount
298
+ }
299
+
300
+ /**
301
+ * Run the auto-setup process for Uptime Kuma
302
+ */
303
+ async setup(options: AutoSetupOptions): Promise<AutoSetupResult> {
304
+ const { username, password } = options
305
+
306
+ try {
307
+ // Check if reachable
308
+ const healthy = await this.isHealthy()
309
+ if (!healthy) {
310
+ return { success: false, message: "Uptime Kuma not reachable" }
311
+ }
312
+
313
+ // Check if needs initial setup
314
+ const initialized = await this.isInitialized()
315
+
316
+ if (!initialized) {
317
+ // Create admin user
318
+ const setupResult = await this.setupAdmin(username, password)
319
+ if (!setupResult.ok) {
320
+ return { success: false, message: `Setup failed: ${setupResult.msg}` }
321
+ }
322
+ } else {
323
+ // Login with existing credentials
324
+ const loggedIn = await this.login(username, password)
325
+ if (!loggedIn) {
326
+ this.disconnect()
327
+ return { success: false, message: "Login failed - check credentials" }
328
+ }
329
+ }
330
+
331
+ this.disconnect()
332
+ return {
333
+ success: true,
334
+ message: initialized ? "Logged in" : "Admin created",
335
+ data: { adminCreated: !initialized },
336
+ }
337
+ } catch (error) {
338
+ this.disconnect()
339
+ return { success: false, message: `${error}` }
340
+ }
341
+ }
342
+ }