@muhammedaksam/easiarr 0.1.10 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/arr-api.ts +51 -36
- package/src/api/custom-format-api.ts +212 -0
- package/src/api/prowlarr-api.ts +301 -0
- package/src/api/qbittorrent-api.ts +198 -0
- package/src/api/quality-profile-api.ts +205 -0
- package/src/apps/registry.ts +6 -6
- package/src/compose/generator.ts +10 -28
- package/src/data/trash-profiles.ts +252 -0
- package/src/ui/screens/AdvancedSettings.ts +2 -1
- package/src/ui/screens/ApiKeyViewer.ts +5 -23
- package/src/ui/screens/AppConfigurator.ts +53 -93
- package/src/ui/screens/MainMenu.ts +33 -1
- package/src/ui/screens/ProwlarrSetup.ts +376 -0
- package/src/ui/screens/SecretsEditor.ts +9 -50
- package/src/ui/screens/TRaSHProfileSetup.ts +371 -0
- package/src/utils/categories.ts +59 -0
- package/src/utils/debug.ts +27 -0
- package/src/utils/env.ts +98 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "TUI tool for generating docker-compose files for the *arr media ecosystem with 41 apps, TRaSH Guides best practices, VPN routing, and Traefik reverse proxy support",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
package/src/api/arr-api.ts
CHANGED
|
@@ -12,6 +12,13 @@ export interface RootFolder {
|
|
|
12
12
|
unmappedFolders?: { name: string | null; path: string | null; relativePath: string | null }[]
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// Options for adding root folder (some apps like Lidarr need extra fields)
|
|
16
|
+
export interface AddRootFolderOptions {
|
|
17
|
+
path: string
|
|
18
|
+
defaultMetadataProfileId?: number
|
|
19
|
+
defaultQualityProfileId?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
// Types for Download Client API
|
|
16
23
|
export interface DownloadClientConfig {
|
|
17
24
|
name: string
|
|
@@ -26,42 +33,16 @@ export interface DownloadClient extends DownloadClientConfig {
|
|
|
26
33
|
id?: number
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return "movies"
|
|
36
|
-
case "sonarr":
|
|
37
|
-
return "tv"
|
|
38
|
-
case "lidarr":
|
|
39
|
-
return "music"
|
|
40
|
-
case "readarr":
|
|
41
|
-
return "books"
|
|
42
|
-
case "whisparr":
|
|
43
|
-
return "adult"
|
|
44
|
-
default:
|
|
45
|
-
return "default"
|
|
46
|
-
}
|
|
36
|
+
// Types for Remote Path Mapping API
|
|
37
|
+
export interface RemotePathMapping {
|
|
38
|
+
id?: number
|
|
39
|
+
host: string
|
|
40
|
+
remotePath: string
|
|
41
|
+
localPath: string
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
switch (appId) {
|
|
52
|
-
case "radarr":
|
|
53
|
-
return "movieCategory"
|
|
54
|
-
case "sonarr":
|
|
55
|
-
case "whisparr":
|
|
56
|
-
return "tvCategory"
|
|
57
|
-
case "lidarr":
|
|
58
|
-
return "musicCategory"
|
|
59
|
-
case "readarr":
|
|
60
|
-
return "bookCategory"
|
|
61
|
-
default:
|
|
62
|
-
return "category"
|
|
63
|
-
}
|
|
64
|
-
}
|
|
44
|
+
import type { AppId } from "../config/schema"
|
|
45
|
+
import { getCategoryForApp, getCategoryFieldName } from "../utils/categories"
|
|
65
46
|
|
|
66
47
|
// qBittorrent download client config
|
|
67
48
|
export function createQBittorrentConfig(
|
|
@@ -161,13 +142,31 @@ export class ArrApiClient {
|
|
|
161
142
|
return this.request<RootFolder[]>("/rootfolder")
|
|
162
143
|
}
|
|
163
144
|
|
|
164
|
-
async addRootFolder(
|
|
145
|
+
async addRootFolder(pathOrOptions: string | AddRootFolderOptions): Promise<RootFolder> {
|
|
146
|
+
const body = typeof pathOrOptions === "string" ? { path: pathOrOptions } : pathOrOptions
|
|
165
147
|
return this.request<RootFolder>("/rootfolder", {
|
|
166
148
|
method: "POST",
|
|
167
|
-
body: JSON.stringify(
|
|
149
|
+
body: JSON.stringify(body),
|
|
168
150
|
})
|
|
169
151
|
}
|
|
170
152
|
|
|
153
|
+
// Profile methods (needed for Lidarr root folders)
|
|
154
|
+
async getMetadataProfiles(): Promise<{ id: number; name: string }[]> {
|
|
155
|
+
try {
|
|
156
|
+
return await this.request<{ id: number; name: string }[]>("/metadataprofile")
|
|
157
|
+
} catch {
|
|
158
|
+
return []
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async getQualityProfiles(): Promise<{ id: number; name: string }[]> {
|
|
163
|
+
try {
|
|
164
|
+
return await this.request<{ id: number; name: string }[]>("/qualityprofile")
|
|
165
|
+
} catch {
|
|
166
|
+
return []
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
171
170
|
async deleteRootFolder(id: number): Promise<void> {
|
|
172
171
|
await this.request(`/rootfolder/${id}`, { method: "DELETE" })
|
|
173
172
|
}
|
|
@@ -228,6 +227,22 @@ export class ArrApiClient {
|
|
|
228
227
|
body: JSON.stringify(updatedConfig),
|
|
229
228
|
})
|
|
230
229
|
}
|
|
230
|
+
|
|
231
|
+
// Remote Path Mapping methods - for Docker path translation
|
|
232
|
+
async getRemotePathMappings(): Promise<RemotePathMapping[]> {
|
|
233
|
+
return this.request<RemotePathMapping[]>("/remotepathmapping")
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async addRemotePathMapping(host: string, remotePath: string, localPath: string): Promise<RemotePathMapping> {
|
|
237
|
+
return this.request<RemotePathMapping>("/remotepathmapping", {
|
|
238
|
+
method: "POST",
|
|
239
|
+
body: JSON.stringify({ host, remotePath, localPath }),
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async deleteRemotePathMapping(id: number): Promise<void> {
|
|
244
|
+
await this.request(`/remotepathmapping/${id}`, { method: "DELETE" })
|
|
245
|
+
}
|
|
231
246
|
}
|
|
232
247
|
|
|
233
248
|
// Types for Host Config API
|
|
@@ -0,0 +1,212 @@
|
|
|
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(cfs: Omit<CustomFormat, "id">[]): Promise<{ success: number; failed: number }> {
|
|
97
|
+
let success = 0
|
|
98
|
+
let failed = 0
|
|
99
|
+
|
|
100
|
+
for (const cf of cfs) {
|
|
101
|
+
try {
|
|
102
|
+
await this.importCustomFormat(cf)
|
|
103
|
+
success++
|
|
104
|
+
} catch {
|
|
105
|
+
failed++
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { success, failed }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fetch CF from TRaSH GitHub
|
|
113
|
+
static async fetchTRaSHCustomFormat(app: "radarr" | "sonarr", cfName: string): Promise<CustomFormat | null> {
|
|
114
|
+
try {
|
|
115
|
+
const url = `${TRASH_CF_BASE_URL}/${app}/cf/${cfName}.json`
|
|
116
|
+
const response = await fetch(url)
|
|
117
|
+
if (!response.ok) return null
|
|
118
|
+
return (await response.json()) as CustomFormat
|
|
119
|
+
} catch {
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fetch multiple CFs from TRaSH
|
|
125
|
+
static async fetchTRaSHCustomFormats(
|
|
126
|
+
app: "radarr" | "sonarr",
|
|
127
|
+
cfNames: string[]
|
|
128
|
+
): Promise<{ cfs: CustomFormat[]; failed: string[] }> {
|
|
129
|
+
const cfs: CustomFormat[] = []
|
|
130
|
+
const failed: string[] = []
|
|
131
|
+
|
|
132
|
+
for (const name of cfNames) {
|
|
133
|
+
const cf = await this.fetchTRaSHCustomFormat(app, name)
|
|
134
|
+
if (cf) {
|
|
135
|
+
cfs.push(cf)
|
|
136
|
+
} else {
|
|
137
|
+
failed.push(name)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { cfs, failed }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Common TRaSH Custom Format names
|
|
146
|
+
export const TRASH_CF_NAMES = {
|
|
147
|
+
radarr: {
|
|
148
|
+
unwanted: ["br-disk", "lq", "lq-release-title", "3d", "x265-hd", "extras"],
|
|
149
|
+
hdr: [
|
|
150
|
+
"dv-hdr10plus",
|
|
151
|
+
"dv-hdr10",
|
|
152
|
+
"dv",
|
|
153
|
+
"dv-hlg",
|
|
154
|
+
"dv-sdr",
|
|
155
|
+
"hdr10plus",
|
|
156
|
+
"hdr10",
|
|
157
|
+
"hdr",
|
|
158
|
+
"hdr-undefined",
|
|
159
|
+
"pq",
|
|
160
|
+
"hlg",
|
|
161
|
+
],
|
|
162
|
+
audio: [
|
|
163
|
+
"truehd-atmos",
|
|
164
|
+
"dts-x",
|
|
165
|
+
"truehd",
|
|
166
|
+
"dts-hd-ma",
|
|
167
|
+
"flac",
|
|
168
|
+
"pcm",
|
|
169
|
+
"dts-hd-hra",
|
|
170
|
+
"ddplus-atmos",
|
|
171
|
+
"ddplus",
|
|
172
|
+
"dts-es",
|
|
173
|
+
"dts",
|
|
174
|
+
"aac",
|
|
175
|
+
"dd",
|
|
176
|
+
],
|
|
177
|
+
streaming: ["amzn", "atvp", "dsnp", "hbo", "hmax", "hulu", "ma", "nf", "pcok", "pmtp"],
|
|
178
|
+
movieVersions: ["imax-enhanced", "imax", "hybrid", "criterion-collection", "special-edition", "theatrical-cut"],
|
|
179
|
+
misc: ["repack-proper", "repack2", "multi", "hq-remux", "hq-webdl", "hq"],
|
|
180
|
+
},
|
|
181
|
+
sonarr: {
|
|
182
|
+
unwanted: ["br-disk", "lq", "lq-release-title", "x265-hd", "extras"],
|
|
183
|
+
hdr: [
|
|
184
|
+
"dv-hdr10plus",
|
|
185
|
+
"dv-hdr10",
|
|
186
|
+
"dv",
|
|
187
|
+
"dv-hlg",
|
|
188
|
+
"dv-sdr",
|
|
189
|
+
"hdr10plus",
|
|
190
|
+
"hdr10",
|
|
191
|
+
"hdr",
|
|
192
|
+
"hdr-undefined",
|
|
193
|
+
"pq",
|
|
194
|
+
"hlg",
|
|
195
|
+
],
|
|
196
|
+
streaming: ["amzn", "atvp", "dsnp", "hbo", "hmax", "hulu", "nf", "pcok", "pmtp"],
|
|
197
|
+
hqGroups: ["web-tier-01", "web-tier-02", "web-tier-03"],
|
|
198
|
+
misc: ["repack-proper", "repack2", "multi"],
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Get all CF names for a category
|
|
203
|
+
export function getAllCFNames(app: "radarr" | "sonarr"): string[] {
|
|
204
|
+
const cfNames = TRASH_CF_NAMES[app]
|
|
205
|
+
return Object.values(cfNames).flat()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Get CF names for specific categories
|
|
209
|
+
export function getCFNamesForCategories(app: "radarr" | "sonarr", categories: string[]): string[] {
|
|
210
|
+
const cfNames = TRASH_CF_NAMES[app] as Record<string, string[]>
|
|
211
|
+
return categories.flatMap((cat) => cfNames[cat] || [])
|
|
212
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prowlarr API Client
|
|
3
|
+
* Manages Indexer Proxies, Sync Profiles, and FlareSolverr integration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface IndexerProxy {
|
|
7
|
+
id?: number
|
|
8
|
+
name: string
|
|
9
|
+
tags: number[]
|
|
10
|
+
implementation: string
|
|
11
|
+
configContract: string
|
|
12
|
+
fields: { name: string; value: unknown }[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SyncProfile {
|
|
16
|
+
id?: number
|
|
17
|
+
name: string
|
|
18
|
+
enableRss: boolean
|
|
19
|
+
enableInteractiveSearch: boolean
|
|
20
|
+
enableAutomaticSearch: boolean
|
|
21
|
+
minimumSeeders: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Tag {
|
|
25
|
+
id: number
|
|
26
|
+
label: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Application {
|
|
30
|
+
id?: number
|
|
31
|
+
name: string
|
|
32
|
+
syncLevel: "disabled" | "addOnly" | "fullSync"
|
|
33
|
+
implementation: string
|
|
34
|
+
configContract: string
|
|
35
|
+
fields: { name: string; value: unknown }[]
|
|
36
|
+
tags: number[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ArrAppType = "Radarr" | "Sonarr" | "Lidarr" | "Readarr"
|
|
40
|
+
|
|
41
|
+
export class ProwlarrClient {
|
|
42
|
+
private baseUrl: string
|
|
43
|
+
private apiKey: string
|
|
44
|
+
|
|
45
|
+
constructor(host: string, port: number, apiKey: string) {
|
|
46
|
+
this.baseUrl = `http://${host}:${port}/api/v1`
|
|
47
|
+
this.apiKey = apiKey
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
51
|
+
const url = `${this.baseUrl}${endpoint}`
|
|
52
|
+
const headers: Record<string, string> = {
|
|
53
|
+
"X-Api-Key": this.apiKey,
|
|
54
|
+
"Content-Type": "application/json",
|
|
55
|
+
...((options.headers as Record<string, string>) || {}),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const response = await fetch(url, { ...options, headers })
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error(`Prowlarr API request failed: ${response.status} ${response.statusText}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const text = await response.text()
|
|
65
|
+
if (!text) return {} as T
|
|
66
|
+
return JSON.parse(text) as T
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Health check
|
|
70
|
+
async isHealthy(): Promise<boolean> {
|
|
71
|
+
try {
|
|
72
|
+
await this.request("/health")
|
|
73
|
+
return true
|
|
74
|
+
} catch {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Tag management
|
|
80
|
+
async getTags(): Promise<Tag[]> {
|
|
81
|
+
return this.request<Tag[]>("/tag")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async createTag(label: string): Promise<Tag> {
|
|
85
|
+
return this.request<Tag>("/tag", {
|
|
86
|
+
method: "POST",
|
|
87
|
+
body: JSON.stringify({ label }),
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async getOrCreateTag(label: string): Promise<Tag> {
|
|
92
|
+
const tags = await this.getTags()
|
|
93
|
+
const existing = tags.find((t) => t.label.toLowerCase() === label.toLowerCase())
|
|
94
|
+
if (existing) return existing
|
|
95
|
+
return this.createTag(label)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Indexer Proxy management
|
|
99
|
+
async getIndexerProxies(): Promise<IndexerProxy[]> {
|
|
100
|
+
return this.request<IndexerProxy[]>("/indexerproxy")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async addHttpProxy(
|
|
104
|
+
name: string,
|
|
105
|
+
host: string,
|
|
106
|
+
port: number,
|
|
107
|
+
tags: number[] = [],
|
|
108
|
+
username?: string,
|
|
109
|
+
password?: string
|
|
110
|
+
): Promise<IndexerProxy> {
|
|
111
|
+
const fields: { name: string; value: unknown }[] = [
|
|
112
|
+
{ name: "host", value: host },
|
|
113
|
+
{ name: "port", value: port },
|
|
114
|
+
{ name: "username", value: username || "" },
|
|
115
|
+
{ name: "password", value: password || "" },
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
return this.request<IndexerProxy>("/indexerproxy", {
|
|
119
|
+
method: "POST",
|
|
120
|
+
body: JSON.stringify({
|
|
121
|
+
name,
|
|
122
|
+
tags,
|
|
123
|
+
implementation: "Http",
|
|
124
|
+
configContract: "HttpSettings",
|
|
125
|
+
fields,
|
|
126
|
+
}),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async addSocks5Proxy(
|
|
131
|
+
name: string,
|
|
132
|
+
host: string,
|
|
133
|
+
port: number,
|
|
134
|
+
tags: number[] = [],
|
|
135
|
+
username?: string,
|
|
136
|
+
password?: string
|
|
137
|
+
): Promise<IndexerProxy> {
|
|
138
|
+
const fields: { name: string; value: unknown }[] = [
|
|
139
|
+
{ name: "host", value: host },
|
|
140
|
+
{ name: "port", value: port },
|
|
141
|
+
{ name: "username", value: username || "" },
|
|
142
|
+
{ name: "password", value: password || "" },
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
return this.request<IndexerProxy>("/indexerproxy", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
body: JSON.stringify({
|
|
148
|
+
name,
|
|
149
|
+
tags,
|
|
150
|
+
implementation: "Socks5",
|
|
151
|
+
configContract: "Socks5Settings",
|
|
152
|
+
fields,
|
|
153
|
+
}),
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async addFlareSolverr(name: string, host: string, tags: number[] = [], requestTimeout = 60): Promise<IndexerProxy> {
|
|
158
|
+
const fields: { name: string; value: unknown }[] = [
|
|
159
|
+
{ name: "host", value: host },
|
|
160
|
+
{ name: "requestTimeout", value: requestTimeout },
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
return this.request<IndexerProxy>("/indexerproxy", {
|
|
164
|
+
method: "POST",
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
name,
|
|
167
|
+
tags,
|
|
168
|
+
implementation: "FlareSolverr",
|
|
169
|
+
configContract: "FlareSolverrSettings",
|
|
170
|
+
fields,
|
|
171
|
+
}),
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async deleteIndexerProxy(id: number): Promise<void> {
|
|
176
|
+
await this.request(`/indexerproxy/${id}`, { method: "DELETE" })
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Sync Profile management
|
|
180
|
+
async getSyncProfiles(): Promise<SyncProfile[]> {
|
|
181
|
+
return this.request<SyncProfile[]>("/syncprofile")
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async createSyncProfile(profile: Omit<SyncProfile, "id">): Promise<SyncProfile> {
|
|
185
|
+
return this.request<SyncProfile>("/syncprofile", {
|
|
186
|
+
method: "POST",
|
|
187
|
+
body: JSON.stringify(profile),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Create TRaSH-recommended sync profiles for limited API indexers
|
|
192
|
+
async createLimitedAPISyncProfiles(): Promise<{ automatic: SyncProfile; interactive: SyncProfile }> {
|
|
193
|
+
const existingProfiles = await this.getSyncProfiles()
|
|
194
|
+
|
|
195
|
+
const findByName = (name: string) => existingProfiles.find((p) => p.name === name)
|
|
196
|
+
|
|
197
|
+
// Automatic Search profile (disable RSS)
|
|
198
|
+
let automatic = findByName("Automatic Search")
|
|
199
|
+
if (!automatic) {
|
|
200
|
+
automatic = await this.createSyncProfile({
|
|
201
|
+
name: "Automatic Search",
|
|
202
|
+
enableRss: false,
|
|
203
|
+
enableInteractiveSearch: true,
|
|
204
|
+
enableAutomaticSearch: true,
|
|
205
|
+
minimumSeeders: 1,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Interactive Search profile (disable RSS and Automatic)
|
|
210
|
+
let interactive = findByName("Interactive Search")
|
|
211
|
+
if (!interactive) {
|
|
212
|
+
interactive = await this.createSyncProfile({
|
|
213
|
+
name: "Interactive Search",
|
|
214
|
+
enableRss: false,
|
|
215
|
+
enableInteractiveSearch: true,
|
|
216
|
+
enableAutomaticSearch: false,
|
|
217
|
+
minimumSeeders: 1,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { automatic, interactive }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Configure FlareSolverr for Cloudflare-protected indexers
|
|
225
|
+
async configureFlareSolverr(flareSolverrHost: string): Promise<void> {
|
|
226
|
+
// Create flaresolverr tag
|
|
227
|
+
const tag = await this.getOrCreateTag("flaresolverr")
|
|
228
|
+
|
|
229
|
+
// Check if FlareSolverr proxy already exists
|
|
230
|
+
const proxies = await this.getIndexerProxies()
|
|
231
|
+
const existingFS = proxies.find((p) => p.implementation === "FlareSolverr")
|
|
232
|
+
|
|
233
|
+
if (!existingFS) {
|
|
234
|
+
await this.addFlareSolverr("FlareSolverr", flareSolverrHost, [tag.id])
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Application management (sync *arr apps)
|
|
239
|
+
async getApplications(): Promise<Application[]> {
|
|
240
|
+
return this.request<Application[]>("/applications")
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async addApplication(
|
|
244
|
+
appType: ArrAppType,
|
|
245
|
+
name: string,
|
|
246
|
+
prowlarrUrl: string,
|
|
247
|
+
appUrl: string,
|
|
248
|
+
appApiKey: string,
|
|
249
|
+
syncLevel: "disabled" | "addOnly" | "fullSync" = "fullSync"
|
|
250
|
+
): Promise<Application> {
|
|
251
|
+
const fields: { name: string; value: unknown }[] = [
|
|
252
|
+
{ name: "prowlarrUrl", value: prowlarrUrl },
|
|
253
|
+
{ name: "baseUrl", value: appUrl },
|
|
254
|
+
{ name: "apiKey", value: appApiKey },
|
|
255
|
+
{ name: "syncCategories", value: [] },
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
return this.request<Application>("/applications", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
name,
|
|
262
|
+
syncLevel,
|
|
263
|
+
implementation: appType,
|
|
264
|
+
configContract: `${appType}Settings`,
|
|
265
|
+
fields,
|
|
266
|
+
tags: [],
|
|
267
|
+
}),
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async deleteApplication(id: number): Promise<void> {
|
|
272
|
+
await this.request(`/applications/${id}`, { method: "DELETE" })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Sync all apps - triggers Prowlarr to push indexers to connected apps
|
|
276
|
+
async syncApplications(): Promise<void> {
|
|
277
|
+
await this.request("/applications/action/sync", { method: "POST" })
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Add *arr app with auto-detection
|
|
281
|
+
async addArrApp(
|
|
282
|
+
appType: ArrAppType,
|
|
283
|
+
host: string,
|
|
284
|
+
port: number,
|
|
285
|
+
apiKey: string,
|
|
286
|
+
prowlarrHost: string,
|
|
287
|
+
prowlarrPort: number
|
|
288
|
+
): Promise<Application> {
|
|
289
|
+
const prowlarrUrl = `http://${prowlarrHost}:${prowlarrPort}`
|
|
290
|
+
const appUrl = `http://${host}:${port}`
|
|
291
|
+
|
|
292
|
+
// Check if app already exists
|
|
293
|
+
const apps = await this.getApplications()
|
|
294
|
+
const existing = apps.find((a) => a.implementation === appType)
|
|
295
|
+
if (existing) {
|
|
296
|
+
return existing
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return this.addApplication(appType, appType, prowlarrUrl, appUrl, apiKey)
|
|
300
|
+
}
|
|
301
|
+
}
|