@muhammedaksam/easiarr 0.2.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muhammedaksam/easiarr",
3
- "version": "0.2.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",
@@ -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
@@ -135,13 +142,31 @@ export class ArrApiClient {
135
142
  return this.request<RootFolder[]>("/rootfolder")
136
143
  }
137
144
 
138
- async addRootFolder(path: string): Promise<RootFolder> {
145
+ async addRootFolder(pathOrOptions: string | AddRootFolderOptions): Promise<RootFolder> {
146
+ const body = typeof pathOrOptions === "string" ? { path: pathOrOptions } : pathOrOptions
139
147
  return this.request<RootFolder>("/rootfolder", {
140
148
  method: "POST",
141
- body: JSON.stringify({ path }),
149
+ body: JSON.stringify(body),
142
150
  })
143
151
  }
144
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
+
145
170
  async deleteRootFolder(id: number): Promise<void> {
146
171
  await this.request(`/rootfolder/${id}`, { method: "DELETE" })
147
172
  }
@@ -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
+ }