@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.
- package/dist/index.js +29837 -0
- package/package.json +15 -15
- package/src/VersionInfo.ts +0 -12
- package/src/api/arr-api.ts +0 -521
- package/src/api/auto-setup-types.ts +0 -64
- package/src/api/base-api.ts +0 -216
- package/src/api/bazarr-api.ts +0 -297
- package/src/api/cloudflare-api.ts +0 -493
- package/src/api/custom-format-api.ts +0 -224
- package/src/api/grafana-api.ts +0 -230
- package/src/api/heimdall-api.ts +0 -141
- package/src/api/homarr-api.ts +0 -209
- package/src/api/huntarr-api.ts +0 -457
- package/src/api/index.ts +0 -9
- package/src/api/jellyfin-api.ts +0 -389
- package/src/api/jellyseerr-api.ts +0 -559
- package/src/api/maintainerr-api.ts +0 -461
- package/src/api/naming-config.ts +0 -101
- package/src/api/overseerr-api.ts +0 -392
- package/src/api/plex-api.ts +0 -308
- package/src/api/portainer-api.ts +0 -374
- package/src/api/profilarr-api.ts +0 -391
- package/src/api/prowlarr-api.ts +0 -488
- package/src/api/qbittorrent-api.ts +0 -300
- package/src/api/quality-profile-api.ts +0 -272
- package/src/api/tautulli-api.ts +0 -244
- package/src/api/uptime-kuma-api.ts +0 -343
- package/src/apps/categories.ts +0 -14
- package/src/apps/index.ts +0 -2
- package/src/apps/registry.ts +0 -1275
- package/src/compose/caddy-config.ts +0 -130
- package/src/compose/generator.ts +0 -287
- package/src/compose/index.ts +0 -7
- package/src/compose/templates.ts +0 -78
- package/src/compose/traefik-config.ts +0 -187
- package/src/config/bookmarks-generator.ts +0 -160
- package/src/config/defaults.ts +0 -37
- package/src/config/homepage-config.ts +0 -381
- package/src/config/index.ts +0 -6
- package/src/config/manager.ts +0 -150
- package/src/config/recyclarr-config.ts +0 -181
- package/src/config/schema.ts +0 -310
- package/src/config/slskd-config.ts +0 -86
- package/src/config/soularr-config.ts +0 -105
- package/src/config/trash-quality-definitions.ts +0 -213
- package/src/data/lidarr-custom-formats.ts +0 -127
- package/src/data/trash-profiles.ts +0 -333
- package/src/docker/client.ts +0 -312
- package/src/docker/index.ts +0 -18
- package/src/index.ts +0 -77
- package/src/setup/actions/arr-common.ts +0 -307
- package/src/setup/actions/dashboards.ts +0 -79
- package/src/setup/actions/download-clients.ts +0 -54
- package/src/setup/actions/jellyseerr.ts +0 -331
- package/src/setup/actions/media-servers.ts +0 -68
- package/src/setup/actions/monitoring.ts +0 -120
- package/src/setup/actions/prowlarr.ts +0 -137
- package/src/setup/actions/utilities.ts +0 -490
- package/src/setup/index.ts +0 -78
- package/src/setup/types.ts +0 -144
- package/src/structure/manager.ts +0 -129
- package/src/ui/App.ts +0 -111
- package/src/ui/components/ApplicationSelector.ts +0 -275
- package/src/ui/components/CredentialsForm.ts +0 -180
- package/src/ui/components/DownloadClientForm.ts +0 -114
- package/src/ui/components/FileEditor.ts +0 -91
- package/src/ui/components/FooterHint.ts +0 -119
- package/src/ui/components/PageLayout.ts +0 -160
- package/src/ui/components/UpdateNotification.ts +0 -102
- package/src/ui/index.ts +0 -7
- package/src/ui/screens/AdvancedSettings.ts +0 -188
- package/src/ui/screens/ApiKeyViewer.ts +0 -511
- package/src/ui/screens/AppConfigurator.ts +0 -543
- package/src/ui/screens/AppManager.ts +0 -284
- package/src/ui/screens/ContainerControl.ts +0 -581
- package/src/ui/screens/LogsViewer.ts +0 -478
- package/src/ui/screens/MainMenu.ts +0 -299
- package/src/ui/screens/MonitorDashboard.ts +0 -881
- package/src/ui/screens/SecretsEditor.ts +0 -216
- package/src/ui/screens/SettingsScreen.ts +0 -633
- package/src/ui/screens/index.ts +0 -22
- package/src/ui/screens/setup/BaseAppSetupScreen.ts +0 -375
- package/src/ui/screens/setup/CloudflaredSetup.ts +0 -460
- package/src/ui/screens/setup/FullAutoSetup.ts +0 -766
- package/src/ui/screens/setup/HomepageSetup.ts +0 -165
- package/src/ui/screens/setup/JellyfinSetup.ts +0 -328
- package/src/ui/screens/setup/JellyseerrSetup.ts +0 -242
- package/src/ui/screens/setup/ProwlarrSetup.ts +0 -500
- package/src/ui/screens/setup/QBittorrentSetup.ts +0 -221
- package/src/ui/screens/setup/QuickSetup.ts +0 -1169
- package/src/ui/screens/setup/RecyclarrSetup.ts +0 -270
- package/src/ui/screens/setup/TRaSHProfileSetup.ts +0 -381
- package/src/utils/arch.ts +0 -78
- package/src/utils/browser.ts +0 -27
- package/src/utils/categories.ts +0 -61
- package/src/utils/debug.ts +0 -84
- package/src/utils/env.ts +0 -138
- package/src/utils/index.ts +0 -8
- package/src/utils/logs.ts +0 -119
- package/src/utils/migrations/1765626338_rename_env_variables.ts +0 -102
- package/src/utils/migrations/1765707135_rename_easiarr_status.ts +0 -91
- package/src/utils/migrations/1765732722_remove_cloudflare_dns_api_token.ts +0 -45
- package/src/utils/migrations/1769515650_migrate_to_xdg.ts +0 -114
- package/src/utils/migrations.ts +0 -165
- package/src/utils/password.ts +0 -42
- package/src/utils/paths.ts +0 -33
- package/src/utils/unraid.ts +0 -103
- package/src/utils/update-checker.ts +0 -210
- 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
|
-
}
|