@muhammedaksam/easiarr 1.4.0 → 1.4.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.
Files changed (109) hide show
  1. package/dist/index.js +29837 -0
  2. package/package.json +15 -15
  3. package/src/VersionInfo.ts +0 -12
  4. package/src/api/arr-api.ts +0 -521
  5. package/src/api/auto-setup-types.ts +0 -64
  6. package/src/api/base-api.ts +0 -216
  7. package/src/api/bazarr-api.ts +0 -297
  8. package/src/api/cloudflare-api.ts +0 -493
  9. package/src/api/custom-format-api.ts +0 -224
  10. package/src/api/grafana-api.ts +0 -230
  11. package/src/api/heimdall-api.ts +0 -141
  12. package/src/api/homarr-api.ts +0 -209
  13. package/src/api/huntarr-api.ts +0 -457
  14. package/src/api/index.ts +0 -9
  15. package/src/api/jellyfin-api.ts +0 -389
  16. package/src/api/jellyseerr-api.ts +0 -559
  17. package/src/api/maintainerr-api.ts +0 -461
  18. package/src/api/naming-config.ts +0 -101
  19. package/src/api/overseerr-api.ts +0 -392
  20. package/src/api/plex-api.ts +0 -308
  21. package/src/api/portainer-api.ts +0 -374
  22. package/src/api/profilarr-api.ts +0 -391
  23. package/src/api/prowlarr-api.ts +0 -488
  24. package/src/api/qbittorrent-api.ts +0 -300
  25. package/src/api/quality-profile-api.ts +0 -272
  26. package/src/api/tautulli-api.ts +0 -244
  27. package/src/api/uptime-kuma-api.ts +0 -343
  28. package/src/apps/categories.ts +0 -14
  29. package/src/apps/index.ts +0 -2
  30. package/src/apps/registry.ts +0 -1275
  31. package/src/compose/caddy-config.ts +0 -130
  32. package/src/compose/generator.ts +0 -287
  33. package/src/compose/index.ts +0 -7
  34. package/src/compose/templates.ts +0 -78
  35. package/src/compose/traefik-config.ts +0 -187
  36. package/src/config/bookmarks-generator.ts +0 -160
  37. package/src/config/defaults.ts +0 -37
  38. package/src/config/homepage-config.ts +0 -381
  39. package/src/config/index.ts +0 -6
  40. package/src/config/manager.ts +0 -150
  41. package/src/config/recyclarr-config.ts +0 -181
  42. package/src/config/schema.ts +0 -310
  43. package/src/config/slskd-config.ts +0 -86
  44. package/src/config/soularr-config.ts +0 -105
  45. package/src/config/trash-quality-definitions.ts +0 -213
  46. package/src/data/lidarr-custom-formats.ts +0 -127
  47. package/src/data/trash-profiles.ts +0 -333
  48. package/src/docker/client.ts +0 -312
  49. package/src/docker/index.ts +0 -18
  50. package/src/index.ts +0 -77
  51. package/src/setup/actions/arr-common.ts +0 -307
  52. package/src/setup/actions/dashboards.ts +0 -79
  53. package/src/setup/actions/download-clients.ts +0 -54
  54. package/src/setup/actions/jellyseerr.ts +0 -331
  55. package/src/setup/actions/media-servers.ts +0 -68
  56. package/src/setup/actions/monitoring.ts +0 -120
  57. package/src/setup/actions/prowlarr.ts +0 -137
  58. package/src/setup/actions/utilities.ts +0 -490
  59. package/src/setup/index.ts +0 -78
  60. package/src/setup/types.ts +0 -144
  61. package/src/structure/manager.ts +0 -129
  62. package/src/ui/App.ts +0 -111
  63. package/src/ui/components/ApplicationSelector.ts +0 -275
  64. package/src/ui/components/CredentialsForm.ts +0 -180
  65. package/src/ui/components/DownloadClientForm.ts +0 -114
  66. package/src/ui/components/FileEditor.ts +0 -91
  67. package/src/ui/components/FooterHint.ts +0 -119
  68. package/src/ui/components/PageLayout.ts +0 -160
  69. package/src/ui/components/UpdateNotification.ts +0 -102
  70. package/src/ui/index.ts +0 -7
  71. package/src/ui/screens/AdvancedSettings.ts +0 -188
  72. package/src/ui/screens/ApiKeyViewer.ts +0 -511
  73. package/src/ui/screens/AppConfigurator.ts +0 -543
  74. package/src/ui/screens/AppManager.ts +0 -284
  75. package/src/ui/screens/ContainerControl.ts +0 -581
  76. package/src/ui/screens/LogsViewer.ts +0 -478
  77. package/src/ui/screens/MainMenu.ts +0 -299
  78. package/src/ui/screens/MonitorDashboard.ts +0 -881
  79. package/src/ui/screens/SecretsEditor.ts +0 -216
  80. package/src/ui/screens/SettingsScreen.ts +0 -633
  81. package/src/ui/screens/index.ts +0 -22
  82. package/src/ui/screens/setup/BaseAppSetupScreen.ts +0 -375
  83. package/src/ui/screens/setup/CloudflaredSetup.ts +0 -460
  84. package/src/ui/screens/setup/FullAutoSetup.ts +0 -766
  85. package/src/ui/screens/setup/HomepageSetup.ts +0 -165
  86. package/src/ui/screens/setup/JellyfinSetup.ts +0 -328
  87. package/src/ui/screens/setup/JellyseerrSetup.ts +0 -242
  88. package/src/ui/screens/setup/ProwlarrSetup.ts +0 -500
  89. package/src/ui/screens/setup/QBittorrentSetup.ts +0 -221
  90. package/src/ui/screens/setup/QuickSetup.ts +0 -1169
  91. package/src/ui/screens/setup/RecyclarrSetup.ts +0 -270
  92. package/src/ui/screens/setup/TRaSHProfileSetup.ts +0 -381
  93. package/src/utils/arch.ts +0 -78
  94. package/src/utils/browser.ts +0 -27
  95. package/src/utils/categories.ts +0 -61
  96. package/src/utils/debug.ts +0 -84
  97. package/src/utils/env.ts +0 -138
  98. package/src/utils/index.ts +0 -8
  99. package/src/utils/logs.ts +0 -119
  100. package/src/utils/migrations/1765626338_rename_env_variables.ts +0 -102
  101. package/src/utils/migrations/1765707135_rename_easiarr_status.ts +0 -91
  102. package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +0 -45
  103. package/src/utils/migrations/1769515650_migrate_to_xdg.ts +0 -114
  104. package/src/utils/migrations.ts +0 -165
  105. package/src/utils/password.ts +0 -42
  106. package/src/utils/paths.ts +0 -33
  107. package/src/utils/unraid.ts +0 -103
  108. package/src/utils/update-checker.ts +0 -210
  109. package/src/utils/url-utils.ts +0 -38
@@ -1,493 +0,0 @@
1
- /**
2
- * Cloudflare API client for tunnel and DNS management
3
- */
4
-
5
- import { BaseApiClient } from "./base-api"
6
-
7
- const CLOUDFLARE_API_BASE = "https://api.cloudflare.com/client/v4"
8
-
9
- interface CloudflareResponse<T> {
10
- success: boolean
11
- errors: Array<{ code: number; message: string }>
12
- messages: string[]
13
- result: T
14
- }
15
-
16
- interface Zone {
17
- id: string
18
- name: string
19
- status: string
20
- }
21
-
22
- interface Tunnel {
23
- id: string
24
- name: string
25
- status: string
26
- created_at: string
27
- connections: Array<{
28
- id: string
29
- is_pending_reconnect: boolean
30
- }>
31
- }
32
-
33
- interface TunnelCredentials {
34
- account_tag: string
35
- tunnel_secret: string
36
- tunnel_id: string
37
- tunnel_name: string
38
- }
39
-
40
- interface DnsRecord {
41
- id: string
42
- name: string
43
- type: string
44
- content: string
45
- proxied: boolean
46
- }
47
-
48
- export class CloudflareApi extends BaseApiClient {
49
- protected readonly logPrefix = "CloudflareAPI"
50
- private apiToken: string
51
- private accountId: string | null = null
52
-
53
- constructor(apiToken: string) {
54
- // Use dummy host/port since we use absolute URLs
55
- super("api.cloudflare.com", 443)
56
- this.apiToken = apiToken
57
- }
58
-
59
- private async cfRequest<T>(
60
- method: string,
61
- endpoint: string,
62
- body?: unknown
63
- ): Promise<CloudflareResponse<T>> {
64
- const response = await fetch(`${CLOUDFLARE_API_BASE}${endpoint}`, {
65
- method,
66
- headers: {
67
- Authorization: `Bearer ${this.apiToken}`,
68
- "Content-Type": "application/json",
69
- },
70
- body: body ? JSON.stringify(body) : undefined,
71
- })
72
-
73
- const data = (await response.json()) as CloudflareResponse<T>
74
-
75
- if (!data.success) {
76
- const errors = data.errors.map((e) => e.message).join(", ")
77
- throw new Error(`Cloudflare API error: ${errors}`)
78
- }
79
-
80
- return data
81
- }
82
-
83
- async getAccountId(): Promise<string> {
84
- if (this.accountId) return this.accountId
85
-
86
- const response = await this.cfRequest<{ id: string }[]>("GET", "/accounts")
87
- if (response.result.length === 0) {
88
- throw new Error(
89
- "No Cloudflare accounts found. Your API token is missing the 'Account Settings:Read' permission."
90
- )
91
- }
92
-
93
- this.accountId = response.result[0].id
94
- return this.accountId
95
- }
96
-
97
- async listZones(): Promise<Zone[]> {
98
- const response = await this.cfRequest<Zone[]>("GET", "/zones")
99
- return response.result
100
- }
101
-
102
- async getZoneId(domain: string): Promise<string> {
103
- const response = await this.cfRequest<Zone[]>(
104
- "GET",
105
- `/zones?name=${encodeURIComponent(domain)}`
106
- )
107
- if (response.result.length === 0) {
108
- throw new Error(`Zone not found for domain: ${domain}`)
109
- }
110
- return response.result[0].id
111
- }
112
-
113
- async listTunnels(): Promise<Tunnel[]> {
114
- const accountId = await this.getAccountId()
115
- const response = await this.cfRequest<Tunnel[]>("GET", `/accounts/${accountId}/cfd_tunnel`)
116
- return response.result
117
- }
118
-
119
- async getTunnelByName(name: string): Promise<Tunnel | null> {
120
- const tunnels = await this.listTunnels()
121
- return tunnels.find((t) => t.name === name) || null
122
- }
123
-
124
- async createTunnel(name: string): Promise<{ tunnel: Tunnel; credentials: TunnelCredentials }> {
125
- const accountId = await this.getAccountId()
126
- const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64")
127
-
128
- const response = await this.cfRequest<Tunnel>("POST", `/accounts/${accountId}/cfd_tunnel`, {
129
- name,
130
- tunnel_secret: secret,
131
- config_src: "cloudflare",
132
- })
133
-
134
- return {
135
- tunnel: response.result,
136
- credentials: {
137
- account_tag: accountId,
138
- tunnel_secret: secret,
139
- tunnel_id: response.result.id,
140
- tunnel_name: name,
141
- },
142
- }
143
- }
144
-
145
- async getTunnelToken(tunnelId: string): Promise<string> {
146
- const accountId = await this.getAccountId()
147
- const response = await this.cfRequest<string>(
148
- "GET",
149
- `/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`
150
- )
151
- return response.result
152
- }
153
-
154
- async configureTunnel(
155
- tunnelId: string,
156
- ingress: Array<{ hostname?: string; service: string; originRequest?: Record<string, unknown> }>,
157
- warpRouting = false
158
- ): Promise<void> {
159
- const accountId = await this.getAccountId()
160
-
161
- const hasChatchAll = ingress.some((r) => !r.hostname)
162
- if (!hasChatchAll) {
163
- ingress.push({ service: "http_status:404" })
164
- }
165
-
166
- await this.cfRequest("PUT", `/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, {
167
- config: {
168
- ingress,
169
- "warp-routing": { enabled: warpRouting },
170
- },
171
- })
172
- }
173
-
174
- async listDnsRecords(zoneId: string): Promise<DnsRecord[]> {
175
- const response = await this.cfRequest<DnsRecord[]>("GET", `/zones/${zoneId}/dns_records`)
176
- return response.result
177
- }
178
-
179
- async createDnsRecord(
180
- zoneId: string,
181
- name: string,
182
- tunnelId: string,
183
- proxied = true
184
- ): Promise<DnsRecord> {
185
- const target = `${tunnelId}.cfargotunnel.com`
186
-
187
- const existing = await this.cfRequest<DnsRecord[]>(
188
- "GET",
189
- `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(name)}`
190
- )
191
-
192
- if (existing.result.length > 0) {
193
- const recordId = existing.result[0].id
194
- const response = await this.cfRequest<DnsRecord>(
195
- "PATCH",
196
- `/zones/${zoneId}/dns_records/${recordId}`,
197
- { type: "CNAME", name, content: target, proxied }
198
- )
199
- return response.result
200
- }
201
-
202
- const response = await this.cfRequest<DnsRecord>("POST", `/zones/${zoneId}/dns_records`, {
203
- type: "CNAME",
204
- name,
205
- content: target,
206
- proxied,
207
- })
208
-
209
- return response.result
210
- }
211
-
212
- async deleteTunnel(tunnelId: string): Promise<void> {
213
- const accountId = await this.getAccountId()
214
- await this.cfRequest("DELETE", `/accounts/${accountId}/cfd_tunnel/${tunnelId}`)
215
- }
216
-
217
- // ==================== Zero Trust Private Network API ====================
218
-
219
- async addTunnelRoute(
220
- tunnelId: string,
221
- networkCidr: string,
222
- comment = "easiarr private network"
223
- ): Promise<string> {
224
- const accountId = await this.getAccountId()
225
- const response = await this.cfRequest<{ id: string }>(
226
- "POST",
227
- `/accounts/${accountId}/teamnet/routes`,
228
- { network: networkCidr, tunnel_id: tunnelId, comment }
229
- )
230
- return response.result.id
231
- }
232
-
233
- async listTunnelRoutes(): Promise<
234
- Array<{ id: string; network: string; tunnel_id: string; comment?: string }>
235
- > {
236
- const accountId = await this.getAccountId()
237
- const response = await this.cfRequest<
238
- Array<{ id: string; network: string; tunnel_id: string; comment?: string }>
239
- >("GET", `/accounts/${accountId}/teamnet/routes`)
240
- return response.result
241
- }
242
-
243
- async deleteTunnelRoute(routeId: string): Promise<void> {
244
- const accountId = await this.getAccountId()
245
- await this.cfRequest("DELETE", `/accounts/${accountId}/teamnet/routes/${routeId}`)
246
- }
247
-
248
- async getTunnelRouteForNetwork(
249
- networkCidr: string
250
- ): Promise<{ id: string; tunnel_id: string } | null> {
251
- const routes = await this.listTunnelRoutes()
252
- return routes.find((r) => r.network === networkCidr) || null
253
- }
254
-
255
- // ==================== Cloudflare Access API ====================
256
-
257
- async createAccessApplication(
258
- domain: string,
259
- name = "easiarr",
260
- sessionDuration = "24h"
261
- ): Promise<{ id: string; name: string }> {
262
- const accountId = await this.getAccountId()
263
-
264
- const existing = await this.cfRequest<Array<{ id: string; name: string; domain: string }>>(
265
- "GET",
266
- `/accounts/${accountId}/access/apps`
267
- )
268
-
269
- const existingApp = existing.result.find(
270
- (app) => app.name === name || app.domain === `*.${domain}`
271
- )
272
- if (existingApp) {
273
- return { id: existingApp.id, name: existingApp.name }
274
- }
275
-
276
- const response = await this.cfRequest<{ id: string; name: string }>(
277
- "POST",
278
- `/accounts/${accountId}/access/apps`,
279
- {
280
- name,
281
- domain: `*.${domain}`,
282
- type: "self_hosted",
283
- session_duration: sessionDuration,
284
- auto_redirect_to_identity: true,
285
- }
286
- )
287
-
288
- return response.result
289
- }
290
-
291
- async createAccessPolicy(
292
- appId: string,
293
- allowedEmails: string[],
294
- policyName = "Allow Emails"
295
- ): Promise<{ id: string }> {
296
- const accountId = await this.getAccountId()
297
-
298
- const existing = await this.cfRequest<Array<{ id: string; name: string }>>(
299
- "GET",
300
- `/accounts/${accountId}/access/apps/${appId}/policies`
301
- )
302
-
303
- const existingPolicy = existing.result.find((p) => p.name === policyName)
304
- if (existingPolicy) {
305
- return { id: existingPolicy.id }
306
- }
307
-
308
- const response = await this.cfRequest<{ id: string }>(
309
- "POST",
310
- `/accounts/${accountId}/access/apps/${appId}/policies`,
311
- {
312
- name: policyName,
313
- decision: "allow",
314
- include: allowedEmails.map((email) => ({ email: { email } })),
315
- precedence: existing.result.length + 1,
316
- }
317
- )
318
-
319
- return response.result
320
- }
321
-
322
- async createBypassPolicy(
323
- appId: string,
324
- bypassIp: string,
325
- policyName = "easiarr-web-bypass"
326
- ): Promise<{ id: string }> {
327
- const accountId = await this.getAccountId()
328
-
329
- const existing = await this.cfRequest<Array<{ id: string; name: string }>>(
330
- "GET",
331
- `/accounts/${accountId}/access/apps/${appId}/policies`
332
- )
333
-
334
- const existingPolicy = existing.result.find((p) => p.name === policyName)
335
- if (existingPolicy) {
336
- return { id: existingPolicy.id }
337
- }
338
-
339
- const response = await this.cfRequest<{ id: string }>(
340
- "POST",
341
- `/accounts/${accountId}/access/apps/${appId}/policies`,
342
- {
343
- name: policyName,
344
- decision: "bypass",
345
- include: [{ ip: { ip: bypassIp } }],
346
- precedence: existing.result.length + 1,
347
- }
348
- )
349
-
350
- return response.result
351
- }
352
-
353
- async setupAccessProtection(
354
- domain: string,
355
- allowedEmails: string[],
356
- appName = "easiarr",
357
- bypassIp?: string
358
- ): Promise<{ appId: string; policyId: string; bypassPolicyId?: string }> {
359
- const app = await this.createAccessApplication(domain, appName)
360
- const policy = await this.createAccessPolicy(app.id, allowedEmails, "easiarr-web-allow")
361
-
362
- let bypassPolicyId: string | undefined
363
- if (bypassIp) {
364
- const bypassPolicy = await this.createBypassPolicy(app.id, bypassIp, "easiarr-web-bypass")
365
- bypassPolicyId = bypassPolicy.id
366
- }
367
-
368
- return { appId: app.id, policyId: policy.id, bypassPolicyId }
369
- }
370
-
371
- // ==================== WARP Device Enrollment API ====================
372
-
373
- async getDeviceEnrollmentApp(): Promise<{ id: string; name: string } | null> {
374
- const accountId = await this.getAccountId()
375
- const apps = await this.cfRequest<Array<{ id: string; name: string; type: string }>>(
376
- "GET",
377
- `/accounts/${accountId}/access/apps`
378
- )
379
- return apps.result.find((a) => a.type === "warp") || null
380
- }
381
-
382
- async setupDeviceEnrollment(
383
- allowedEmails: string[],
384
- privateNetworkCidr?: string
385
- ): Promise<{ appId: string; allowPolicyId: string; bypassPolicyId?: string }> {
386
- const accountId = await this.getAccountId()
387
-
388
- let warpApp = await this.getDeviceEnrollmentApp()
389
-
390
- if (!warpApp) {
391
- const response = await this.cfRequest<{ id: string; name: string }>(
392
- "POST",
393
- `/accounts/${accountId}/access/apps`,
394
- { type: "warp", name: "Device Enrollment", session_duration: "24h" }
395
- )
396
- warpApp = response.result
397
- }
398
-
399
- const existingPolicies = await this.cfRequest<Array<{ id: string; name: string }>>(
400
- "GET",
401
- `/accounts/${accountId}/access/apps/${warpApp.id}/policies`
402
- )
403
-
404
- let allowPolicyId: string
405
- let bypassPolicyId: string | undefined
406
-
407
- const allowPolicyName = "easiarr-vpn-allow"
408
- const existingAllow = existingPolicies.result.find((p) => p.name === allowPolicyName)
409
- if (existingAllow) {
410
- allowPolicyId = existingAllow.id
411
- } else {
412
- const policy = await this.cfRequest<{ id: string }>(
413
- "POST",
414
- `/accounts/${accountId}/access/apps/${warpApp.id}/policies`,
415
- {
416
- name: allowPolicyName,
417
- decision: "allow",
418
- include: allowedEmails.map((email) => ({ email: { email } })),
419
- precedence: existingPolicies.result.length + 1,
420
- }
421
- )
422
- allowPolicyId = policy.result.id
423
- }
424
-
425
- if (privateNetworkCidr) {
426
- const bypassPolicyName = "easiarr-vpn-bypass"
427
- const existingBypass = existingPolicies.result.find((p) => p.name === bypassPolicyName)
428
- if (existingBypass) {
429
- bypassPolicyId = existingBypass.id
430
- } else {
431
- const bypassPolicy = await this.cfRequest<{ id: string }>(
432
- "POST",
433
- `/accounts/${accountId}/access/apps/${warpApp.id}/policies`,
434
- {
435
- name: bypassPolicyName,
436
- decision: "bypass",
437
- include: [{ ip: { ip: privateNetworkCidr } }],
438
- precedence: existingPolicies.result.length + 2,
439
- }
440
- )
441
- bypassPolicyId = bypassPolicy.result.id
442
- }
443
- }
444
-
445
- return { appId: warpApp.id, allowPolicyId, bypassPolicyId }
446
- }
447
- }
448
-
449
- /**
450
- * Helper to create a fully configured tunnel with DNS
451
- */
452
- export async function setupCloudflaredTunnel(
453
- apiToken: string,
454
- domain: string,
455
- tunnelName = "easiarr",
456
- warpRouting = false
457
- ): Promise<{ tunnelToken: string; tunnelId: string; accountId: string }> {
458
- const api = new CloudflareApi(apiToken)
459
-
460
- const accountId = await api.getAccountId()
461
-
462
- let tunnel = await api.getTunnelByName(tunnelName)
463
- let tunnelToken: string
464
-
465
- if (tunnel) {
466
- tunnelToken = await api.getTunnelToken(tunnel.id)
467
- } else {
468
- const result = await api.createTunnel(tunnelName)
469
- tunnel = result.tunnel
470
- tunnelToken = await api.getTunnelToken(tunnel.id)
471
- }
472
-
473
- await api.configureTunnel(
474
- tunnel.id,
475
- [
476
- {
477
- hostname: `*.${domain}`,
478
- service: "http://traefik:80",
479
- originRequest: {},
480
- },
481
- ],
482
- warpRouting
483
- )
484
-
485
- const zoneId = await api.getZoneId(domain)
486
- await api.createDnsRecord(zoneId, `*.${domain}`, tunnel.id)
487
-
488
- return {
489
- tunnelToken,
490
- tunnelId: tunnel.id,
491
- accountId,
492
- }
493
- }
@@ -1,224 +0,0 @@
1
- /**
2
- * Custom Format API Client
3
- * Manages Custom Formats for Radarr/Sonarr with TRaSH Guides import capability
4
- */
5
-
6
- export interface CFSpecification {
7
- name: string
8
- implementation: string
9
- negate: boolean
10
- required: boolean
11
- fields: { name: string; value: unknown }[]
12
- }
13
-
14
- export interface CustomFormat {
15
- id?: number
16
- name: string
17
- includeCustomFormatWhenRenaming: boolean
18
- specifications: CFSpecification[]
19
- }
20
-
21
- // TRaSH GitHub raw URL for Custom Formats
22
- const TRASH_CF_BASE_URL = "https://raw.githubusercontent.com/TRaSH-Guides/Guides/master/docs/json"
23
-
24
- export class CustomFormatClient {
25
- private baseUrl: string
26
- private apiKey: string
27
- private apiVersion: string
28
-
29
- constructor(host: string, port: number, apiKey: string, apiVersion = "v3") {
30
- this.baseUrl = `http://${host}:${port}/api/${apiVersion}`
31
- this.apiKey = apiKey
32
- this.apiVersion = apiVersion
33
- }
34
-
35
- private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
36
- const url = `${this.baseUrl}${endpoint}`
37
- const headers: Record<string, string> = {
38
- "X-Api-Key": this.apiKey,
39
- "Content-Type": "application/json",
40
- ...((options.headers as Record<string, string>) || {}),
41
- }
42
-
43
- const response = await fetch(url, { ...options, headers })
44
-
45
- if (!response.ok) {
46
- throw new Error(`API request failed: ${response.status} ${response.statusText}`)
47
- }
48
-
49
- const text = await response.text()
50
- if (!text) return {} as T
51
- return JSON.parse(text) as T
52
- }
53
-
54
- // Custom Format CRUD
55
- async getCustomFormats(): Promise<CustomFormat[]> {
56
- return this.request<CustomFormat[]>("/customformat")
57
- }
58
-
59
- async getCustomFormat(id: number): Promise<CustomFormat> {
60
- return this.request<CustomFormat>(`/customformat/${id}`)
61
- }
62
-
63
- async createCustomFormat(cf: Omit<CustomFormat, "id">): Promise<CustomFormat> {
64
- return this.request<CustomFormat>("/customformat", {
65
- method: "POST",
66
- body: JSON.stringify(cf),
67
- })
68
- }
69
-
70
- async updateCustomFormat(id: number, cf: CustomFormat): Promise<CustomFormat> {
71
- return this.request<CustomFormat>(`/customformat/${id}`, {
72
- method: "PUT",
73
- body: JSON.stringify({ ...cf, id }),
74
- })
75
- }
76
-
77
- async deleteCustomFormat(id: number): Promise<void> {
78
- await this.request(`/customformat/${id}`, { method: "DELETE" })
79
- }
80
-
81
- // Import from JSON (TRaSH format)
82
- async importCustomFormat(cfJson: Omit<CustomFormat, "id">): Promise<CustomFormat> {
83
- // Check if CF already exists by name
84
- const existing = await this.getCustomFormats()
85
- const duplicate = existing.find((cf) => cf.name === cfJson.name)
86
-
87
- if (duplicate) {
88
- // Update existing CF
89
- return this.updateCustomFormat(duplicate.id!, { ...cfJson, id: duplicate.id })
90
- }
91
-
92
- return this.createCustomFormat(cfJson)
93
- }
94
-
95
- // Import multiple CFs
96
- async importCustomFormats(
97
- cfs: Omit<CustomFormat, "id">[]
98
- ): Promise<{ success: number; failed: number }> {
99
- let success = 0
100
- let failed = 0
101
-
102
- for (const cf of cfs) {
103
- try {
104
- await this.importCustomFormat(cf)
105
- success++
106
- } catch {
107
- failed++
108
- }
109
- }
110
-
111
- return { success, failed }
112
- }
113
-
114
- // Fetch CF from TRaSH GitHub
115
- static async fetchTRaSHCustomFormat(
116
- app: "radarr" | "sonarr",
117
- cfName: string
118
- ): Promise<CustomFormat | null> {
119
- try {
120
- const url = `${TRASH_CF_BASE_URL}/${app}/cf/${cfName}.json`
121
- const response = await fetch(url)
122
- if (!response.ok) return null
123
- return (await response.json()) as CustomFormat
124
- } catch {
125
- return null
126
- }
127
- }
128
-
129
- // Fetch multiple CFs from TRaSH
130
- static async fetchTRaSHCustomFormats(
131
- app: "radarr" | "sonarr",
132
- cfNames: string[]
133
- ): Promise<{ cfs: CustomFormat[]; failed: string[] }> {
134
- const cfs: CustomFormat[] = []
135
- const failed: string[] = []
136
-
137
- for (const name of cfNames) {
138
- const cf = await this.fetchTRaSHCustomFormat(app, name)
139
- if (cf) {
140
- cfs.push(cf)
141
- } else {
142
- failed.push(name)
143
- }
144
- }
145
-
146
- return { cfs, failed }
147
- }
148
- }
149
-
150
- // Common TRaSH Custom Format names
151
- export const TRASH_CF_NAMES = {
152
- radarr: {
153
- unwanted: ["br-disk", "lq", "lq-release-title", "3d", "x265-hd", "extras"],
154
- hdr: [
155
- "dv-hdr10plus",
156
- "dv-hdr10",
157
- "dv",
158
- "dv-hlg",
159
- "dv-sdr",
160
- "hdr10plus",
161
- "hdr10",
162
- "hdr",
163
- "hdr-undefined",
164
- "pq",
165
- "hlg",
166
- ],
167
- audio: [
168
- "truehd-atmos",
169
- "dts-x",
170
- "truehd",
171
- "dts-hd-ma",
172
- "flac",
173
- "pcm",
174
- "dts-hd-hra",
175
- "ddplus-atmos",
176
- "ddplus",
177
- "dts-es",
178
- "dts",
179
- "aac",
180
- "dd",
181
- ],
182
- streaming: ["amzn", "atvp", "dsnp", "hbo", "hmax", "hulu", "ma", "nf", "pcok", "pmtp"],
183
- movieVersions: [
184
- "imax-enhanced",
185
- "imax",
186
- "hybrid",
187
- "criterion-collection",
188
- "special-edition",
189
- "theatrical-cut",
190
- ],
191
- misc: ["repack-proper", "repack2", "multi", "hq-remux", "hq-webdl", "hq"],
192
- },
193
- sonarr: {
194
- unwanted: ["br-disk", "lq", "lq-release-title", "x265-hd", "extras"],
195
- hdr: [
196
- "dv-hdr10plus",
197
- "dv-hdr10",
198
- "dv",
199
- "dv-hlg",
200
- "dv-sdr",
201
- "hdr10plus",
202
- "hdr10",
203
- "hdr",
204
- "hdr-undefined",
205
- "pq",
206
- "hlg",
207
- ],
208
- streaming: ["amzn", "atvp", "dsnp", "hbo", "hmax", "hulu", "nf", "pcok", "pmtp"],
209
- hqGroups: ["web-tier-01", "web-tier-02", "web-tier-03"],
210
- misc: ["repack-proper", "repack2", "multi"],
211
- },
212
- }
213
-
214
- // Get all CF names for a category
215
- export function getAllCFNames(app: "radarr" | "sonarr"): string[] {
216
- const cfNames = TRASH_CF_NAMES[app]
217
- return Object.values(cfNames).flat()
218
- }
219
-
220
- // Get CF names for specific categories
221
- export function getCFNamesForCategories(app: "radarr" | "sonarr", categories: string[]): string[] {
222
- const cfNames = TRASH_CF_NAMES[app] as Record<string, string[]>
223
- return categories.flatMap((cat) => cfNames[cat] || [])
224
- }