@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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * TRaSH Guide Quality Profile Presets
3
+ * Pre-configured quality profiles based on TRaSH Guides recommendations
4
+ */
5
+
6
+ export interface TRaSHProfilePreset {
7
+ id: string
8
+ name: string
9
+ description: string
10
+ app: "radarr" | "sonarr" | "both"
11
+ cutoffQuality: string
12
+ allowedQualities: string[]
13
+ cfScores: Record<string, number>
14
+ }
15
+
16
+ // Quality names for different resolutions
17
+ export const RADARR_QUALITIES = {
18
+ // HD
19
+ "Bluray-1080p": "Bluray-1080p",
20
+ "WEB-1080p": "WEBDL-1080p",
21
+ "HDTV-1080p": "HDTV-1080p",
22
+ "Bluray-720p": "Bluray-720p",
23
+ "WEB-720p": "WEBDL-720p",
24
+ // UHD
25
+ "Bluray-2160p": "Bluray-2160p",
26
+ "WEB-2160p": "WEBDL-2160p",
27
+ // Remux
28
+ "Remux-1080p": "Remux-1080p",
29
+ "Remux-2160p": "Remux-2160p",
30
+ }
31
+
32
+ export const SONARR_QUALITIES = {
33
+ "WEB-1080p": "WEBDL-1080p",
34
+ "WEB-720p": "WEBDL-720p",
35
+ "WEB-2160p": "WEBDL-2160p",
36
+ "HDTV-1080p": "HDTV-1080p",
37
+ "HDTV-720p": "HDTV-720p",
38
+ "Bluray-1080p": "Bluray-1080p",
39
+ "Bluray-720p": "Bluray-720p",
40
+ "Bluray-2160p": "Bluray-2160p",
41
+ "Remux-1080p": "Remux-1080p",
42
+ "Remux-2160p": "Remux-2160p",
43
+ }
44
+
45
+ // TRaSH recommended Custom Format scores
46
+ export const CF_SCORES = {
47
+ // Unwanted (use negative scores)
48
+ "BR-DISK": -10000,
49
+ LQ: -10000,
50
+ "LQ (Release Title)": -10000,
51
+ "3D": -10000,
52
+ x265: -10000, // Only for HD, not UHD
53
+ Extras: -10000,
54
+
55
+ // Preferred (positive scores)
56
+ "Repack/Proper": 5,
57
+ Repack2: 6,
58
+
59
+ // HDR Formats
60
+ "DV HDR10Plus": 1600,
61
+ "DV HDR10": 1500,
62
+ DV: 1400,
63
+ "DV HLG": 1300,
64
+ "DV SDR": 1200,
65
+ HDR10Plus: 700,
66
+ HDR10: 600,
67
+ HDR: 500,
68
+ "HDR (undefined)": 400,
69
+ PQ: 300,
70
+ HLG: 200,
71
+
72
+ // Audio Formats
73
+ "TrueHD Atmos": 5000,
74
+ "DTS X": 4500,
75
+ TrueHD: 4000,
76
+ "DTS-HD MA": 3500,
77
+ FLAC: 3000,
78
+ PCM: 2500,
79
+ "DTS-HD HRA": 2000,
80
+ "DD+ Atmos": 1500,
81
+ "DD+": 1000,
82
+ "DTS-ES": 800,
83
+ DTS: 600,
84
+ AAC: 400,
85
+ DD: 300,
86
+
87
+ // Streaming Services
88
+ AMZN: 0,
89
+ ATVP: 100,
90
+ DSNP: 100,
91
+ HBO: 0,
92
+ HMAX: 0,
93
+ Hulu: 0,
94
+ MA: 0,
95
+ NF: 0,
96
+ PCOK: 0,
97
+ PMTP: 0,
98
+
99
+ // Movie Versions
100
+ "IMAX Enhanced": 800,
101
+ IMAX: 700,
102
+ Hybrid: 100,
103
+ "Criterion Collection": 100,
104
+ "Special Edition": 50,
105
+ "Theatrical Cut": 0,
106
+
107
+ // HQ Release Groups
108
+ "HQ-Remux": 1750,
109
+ "HQ-WEBDL": 1700,
110
+ HQ: 1600,
111
+ }
112
+
113
+ // Radarr Profile Presets
114
+ export const RADARR_PRESETS: TRaSHProfilePreset[] = [
115
+ {
116
+ id: "hd-bluray-web",
117
+ name: "HD Bluray + WEB",
118
+ description: "High-Quality HD Encodes (Bluray-720p/1080p). Size: 6-15 GB",
119
+ app: "radarr",
120
+ cutoffQuality: "Bluray-1080p",
121
+ allowedQualities: ["Bluray-1080p", "Bluray-720p", "WEBDL-1080p", "WEBDL-720p", "WEBRip-1080p", "WEBRip-720p"],
122
+ cfScores: {
123
+ "BR-DISK": -10000,
124
+ LQ: -10000,
125
+ "LQ (Release Title)": -10000,
126
+ "3D": -10000,
127
+ "x265 (HD)": -10000,
128
+ "Repack/Proper": 5,
129
+ "HQ-WEBDL": 1700,
130
+ HQ: 1600,
131
+ },
132
+ },
133
+ {
134
+ id: "uhd-bluray-web",
135
+ name: "UHD Bluray + WEB",
136
+ description: "High-Quality UHD Encodes (Bluray-2160p). Size: 20-60 GB",
137
+ app: "radarr",
138
+ cutoffQuality: "Bluray-2160p",
139
+ allowedQualities: ["Bluray-2160p", "WEBDL-2160p", "WEBRip-2160p"],
140
+ cfScores: {
141
+ "BR-DISK": -10000,
142
+ LQ: -10000,
143
+ "LQ (Release Title)": -10000,
144
+ "DV HDR10Plus": 1600,
145
+ "DV HDR10": 1500,
146
+ DV: 1400,
147
+ HDR10Plus: 700,
148
+ HDR10: 600,
149
+ "Repack/Proper": 5,
150
+ "TrueHD Atmos": 5000,
151
+ "DTS X": 4500,
152
+ "HQ-WEBDL": 1700,
153
+ },
154
+ },
155
+ {
156
+ id: "remux-web-1080p",
157
+ name: "Remux + WEB 1080p",
158
+ description: "1080p Remuxes. Size: 20-40 GB",
159
+ app: "radarr",
160
+ cutoffQuality: "Remux-1080p",
161
+ allowedQualities: ["Remux-1080p", "WEBDL-1080p", "WEBRip-1080p"],
162
+ cfScores: {
163
+ "BR-DISK": -10000,
164
+ LQ: -10000,
165
+ "x265 (HD)": -10000,
166
+ "HQ-Remux": 1750,
167
+ "Repack/Proper": 5,
168
+ "TrueHD Atmos": 5000,
169
+ "DTS X": 4500,
170
+ TrueHD: 4000,
171
+ "DTS-HD MA": 3500,
172
+ },
173
+ },
174
+ {
175
+ id: "remux-web-2160p",
176
+ name: "Remux + WEB 2160p",
177
+ description: "2160p Remuxes. Size: 40-100 GB",
178
+ app: "radarr",
179
+ cutoffQuality: "Remux-2160p",
180
+ allowedQualities: ["Remux-2160p", "WEBDL-2160p", "WEBRip-2160p"],
181
+ cfScores: {
182
+ "BR-DISK": -10000,
183
+ LQ: -10000,
184
+ "DV HDR10Plus": 1600,
185
+ "DV HDR10": 1500,
186
+ DV: 1400,
187
+ HDR10Plus: 700,
188
+ HDR10: 600,
189
+ "HQ-Remux": 1750,
190
+ "Repack/Proper": 5,
191
+ "TrueHD Atmos": 5000,
192
+ "DTS X": 4500,
193
+ },
194
+ },
195
+ ]
196
+
197
+ // Sonarr Profile Presets
198
+ export const SONARR_PRESETS: TRaSHProfilePreset[] = [
199
+ {
200
+ id: "web-1080p",
201
+ name: "WEB-1080p",
202
+ description: "720p/1080p WEBDL. Balanced quality and size",
203
+ app: "sonarr",
204
+ cutoffQuality: "WEBDL-1080p",
205
+ allowedQualities: ["WEBDL-1080p", "WEBRip-1080p", "WEBDL-720p", "WEBRip-720p"],
206
+ cfScores: {
207
+ "BR-DISK": -10000,
208
+ LQ: -10000,
209
+ "x265 (HD)": -10000,
210
+ "Repack/Proper": 5,
211
+ "HQ-WEBDL": 1700,
212
+ AMZN: 100,
213
+ ATVP: 100,
214
+ DSNP: 100,
215
+ NF: 100,
216
+ },
217
+ },
218
+ {
219
+ id: "web-2160p",
220
+ name: "WEB-2160p",
221
+ description: "2160p WEBDL with HDR. Premium quality",
222
+ app: "sonarr",
223
+ cutoffQuality: "WEBDL-2160p",
224
+ allowedQualities: ["WEBDL-2160p", "WEBRip-2160p"],
225
+ cfScores: {
226
+ "BR-DISK": -10000,
227
+ LQ: -10000,
228
+ "DV HDR10Plus": 1600,
229
+ "DV HDR10": 1500,
230
+ DV: 1400,
231
+ HDR10Plus: 700,
232
+ HDR10: 600,
233
+ "Repack/Proper": 5,
234
+ "HQ-WEBDL": 1700,
235
+ AMZN: 100,
236
+ ATVP: 100,
237
+ NF: 100,
238
+ },
239
+ },
240
+ ]
241
+
242
+ // Get all presets for an app
243
+ export function getPresetsForApp(app: "radarr" | "sonarr"): TRaSHProfilePreset[] {
244
+ if (app === "radarr") return RADARR_PRESETS
245
+ if (app === "sonarr") return SONARR_PRESETS
246
+ return []
247
+ }
248
+
249
+ // Get a specific preset by ID
250
+ export function getPresetById(id: string): TRaSHProfilePreset | undefined {
251
+ return [...RADARR_PRESETS, ...SONARR_PRESETS].find((p) => p.id === id)
252
+ }
@@ -17,6 +17,7 @@ import { createPageLayout } from "../components/PageLayout"
17
17
  import { FileEditor } from "../components/FileEditor"
18
18
  import { readFile, writeFile } from "node:fs/promises"
19
19
  import { getConfigPath, getComposePath } from "../../config/manager"
20
+ import { getEnvPath } from "../../utils/env"
20
21
  import { existsSync } from "node:fs"
21
22
 
22
23
  export class AdvancedSettings {
@@ -100,7 +101,7 @@ export class AdvancedSettings {
100
101
  })
101
102
  break
102
103
  case 1: {
103
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
104
+ const envPath = getEnvPath()
104
105
  await this.editFile(".env", envPath, async (content) => {
105
106
  await writeFile(envPath, content, "utf-8")
106
107
  this.renderMenu()
@@ -1,13 +1,12 @@
1
1
  import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/core"
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs"
3
- import { writeFile, readFile } from "node:fs/promises"
4
3
  import { join } from "node:path"
5
4
  import { randomBytes } from "node:crypto"
6
5
  import { parse as parseYaml } from "yaml"
7
6
  import { createPageLayout } from "../components/PageLayout"
8
7
  import { EasiarrConfig, AppDefinition } from "../../config/schema"
9
8
  import { getApp } from "../../apps/registry"
10
- import { getComposePath } from "../../config/manager"
9
+ import { updateEnv } from "../../utils/env"
11
10
 
12
11
  /** Generate a random 32-character hex API key */
13
12
  function generateApiKey(): string {
@@ -337,30 +336,13 @@ export class ApiKeyViewer extends BoxRenderable {
337
336
  if (foundKeys.length === 0) return
338
337
 
339
338
  try {
340
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
341
-
342
- // Read existing .env if present
343
- const currentEnv: Record<string, string> = {}
344
- if (existsSync(envPath)) {
345
- const content = await readFile(envPath, "utf-8")
346
- content.split("\n").forEach((line) => {
347
- const [key, ...val] = line.split("=")
348
- if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
349
- })
350
- }
351
-
352
- // Add API keys with format API_KEY_SONARR, API_KEY_RADARR, etc.
339
+ // Build updates object with API keys
340
+ const updates: Record<string, string> = {}
353
341
  for (const k of foundKeys) {
354
- const envKey = `API_KEY_${k.appId.toUpperCase()}`
355
- currentEnv[envKey] = k.key
342
+ updates[`API_KEY_${k.appId.toUpperCase()}`] = k.key
356
343
  }
357
344
 
358
- // Reconstruct .env content
359
- const envContent = Object.entries(currentEnv)
360
- .map(([k, v]) => `${k}=${v}`)
361
- .join("\n")
362
-
363
- await writeFile(envPath, envContent, "utf-8")
345
+ await updateEnv(updates)
364
346
 
365
347
  // Update status
366
348
  if (this.statusText) {
@@ -11,13 +11,13 @@ import {
11
11
  InputRenderableEvents,
12
12
  KeyEvent,
13
13
  } from "@opentui/core"
14
- import { existsSync, readFileSync } from "node:fs"
15
- import { writeFile, readFile } from "node:fs/promises"
16
14
  import { createPageLayout } from "../components/PageLayout"
17
15
  import { EasiarrConfig, AppId } from "../../config/schema"
18
16
  import { getApp } from "../../apps/registry"
19
17
  import { ArrApiClient, createQBittorrentConfig, createSABnzbdConfig } from "../../api/arr-api"
20
- import { getComposePath } from "../../config/manager"
18
+ import { QBittorrentClient } from "../../api/qbittorrent-api"
19
+ import { getCategoriesForApps } from "../../utils/categories"
20
+ import { readEnvSync, updateEnv } from "../../utils/env"
21
21
 
22
22
  interface ConfigResult {
23
23
  appId: AppId
@@ -82,29 +82,11 @@ export class AppConfigurator extends BoxRenderable {
82
82
  }
83
83
 
84
84
  private loadSavedCredentials() {
85
- try {
86
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
87
- if (!existsSync(envPath)) return
88
-
89
- const content = readFileSync(envPath, "utf-8")
90
- content.split("\n").forEach((line) => {
91
- const [key, ...val] = line.split("=")
92
- if (key && val.length > 0) {
93
- const value = val.join("=").trim()
94
- if (key.trim() === "GLOBAL_USERNAME") {
95
- this.globalUsername = value
96
- } else if (key.trim() === "GLOBAL_PASSWORD") {
97
- this.globalPassword = value
98
- } else if (key.trim() === "QBITTORRENT_PASSWORD") {
99
- this.qbPass = value
100
- } else if (key.trim() === "SABNZBD_API_KEY") {
101
- this.sabApiKey = value
102
- }
103
- }
104
- })
105
- } catch {
106
- // Ignore errors
107
- }
85
+ const env = readEnvSync()
86
+ if (env.GLOBAL_USERNAME) this.globalUsername = env.GLOBAL_USERNAME
87
+ if (env.GLOBAL_PASSWORD) this.globalPassword = env.GLOBAL_PASSWORD
88
+ if (env.QBITTORRENT_PASSWORD) this.qbPass = env.QBITTORRENT_PASSWORD
89
+ if (env.SABNZBD_API_KEY) this.sabApiKey = env.SABNZBD_API_KEY
108
90
  }
109
91
 
110
92
  private renderCredentialsPrompt() {
@@ -216,32 +198,10 @@ export class AppConfigurator extends BoxRenderable {
216
198
 
217
199
  private async saveGlobalCredentialsToEnv() {
218
200
  try {
219
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
220
-
221
- // Read existing .env if present
222
- const currentEnv: Record<string, string> = {}
223
- if (existsSync(envPath)) {
224
- const content = await readFile(envPath, "utf-8")
225
- content.split("\n").forEach((line) => {
226
- const [key, ...val] = line.split("=")
227
- if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
228
- })
229
- }
230
-
231
- // Add global credentials
232
- if (this.globalUsername) {
233
- currentEnv["GLOBAL_USERNAME"] = this.globalUsername
234
- }
235
- if (this.globalPassword) {
236
- currentEnv["GLOBAL_PASSWORD"] = this.globalPassword
237
- }
238
-
239
- // Reconstruct .env content
240
- const envContent = Object.entries(currentEnv)
241
- .map(([k, v]) => `${k}=${v}`)
242
- .join("\n")
243
-
244
- await writeFile(envPath, envContent, "utf-8")
201
+ const updates: Record<string, string> = {}
202
+ if (this.globalUsername) updates.GLOBAL_USERNAME = this.globalUsername
203
+ if (this.globalPassword) updates.GLOBAL_PASSWORD = this.globalPassword
204
+ await updateEnv(updates)
245
205
  } catch {
246
206
  // Ignore errors - not critical
247
207
  }
@@ -331,8 +291,18 @@ export class AppConfigurator extends BoxRenderable {
331
291
  throw new Error("Already configured")
332
292
  }
333
293
 
334
- // Add root folder
335
- await client.addRootFolder(appDef.rootFolder.path)
294
+ // Add root folder - Lidarr requires profile IDs
295
+ if (appId === "lidarr") {
296
+ const metadataProfiles = await client.getMetadataProfiles()
297
+ const qualityProfiles = await client.getQualityProfiles()
298
+ await client.addRootFolder({
299
+ path: appDef.rootFolder.path,
300
+ defaultMetadataProfileId: metadataProfiles[0]?.id || 1,
301
+ defaultQualityProfileId: qualityProfiles[0]?.id || 1,
302
+ })
303
+ } else {
304
+ await client.addRootFolder(appDef.rootFolder.path)
305
+ }
336
306
 
337
307
  // Set up authentication if credentials provided
338
308
  if (this.globalPassword) {
@@ -346,24 +316,16 @@ export class AppConfigurator extends BoxRenderable {
346
316
 
347
317
  private extractApiKey(appId: AppId): string | null {
348
318
  // Use API keys from .env file (format: API_KEY_APPNAME)
349
- try {
350
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
351
- if (!existsSync(envPath)) return null
352
-
353
- const content = readFileSync(envPath, "utf-8")
354
- const envKey = `API_KEY_${appId.toUpperCase()}`
355
-
356
- for (const line of content.split("\n")) {
357
- const [key, ...val] = line.split("=")
358
- if (key?.trim() === envKey && val.length > 0) {
359
- return val.join("=").trim()
360
- }
361
- }
319
+ const envKey = `API_KEY_${appId.toUpperCase()}`
320
+ return readEnvSync()[envKey] ?? null
321
+ }
362
322
 
363
- return null
364
- } catch {
365
- return null
366
- }
323
+ /**
324
+ * Get qBittorrent categories based on enabled *arr apps
325
+ */
326
+ private getEnabledCategories(): { name: string; savePath: string }[] {
327
+ const enabledAppIds = this.config.apps.filter((a) => a.enabled).map((a) => a.id)
328
+ return getCategoriesForApps(enabledAppIds)
367
329
  }
368
330
 
369
331
  private renderConfigProgress() {
@@ -543,6 +505,22 @@ export class AppConfigurator extends BoxRenderable {
543
505
  }
544
506
 
545
507
  private async addDownloadClients(type: "qbittorrent" | "sabnzbd") {
508
+ // Configure qBittorrent settings via its API first
509
+ if (type === "qbittorrent") {
510
+ try {
511
+ const qbClient = new QBittorrentClient(this.qbHost, this.qbPort, this.qbUser, this.qbPass)
512
+ const loggedIn = await qbClient.login()
513
+ if (loggedIn) {
514
+ // Generate categories from enabled *arr apps that use download clients
515
+ const categories = this.getEnabledCategories()
516
+ // Configure TRaSH-compliant settings: save_path, auto_tmm, categories
517
+ await qbClient.configureTRaSHCompliant(categories)
518
+ }
519
+ } catch {
520
+ // Ignore qBittorrent config errors - may not be ready or have different auth
521
+ }
522
+ }
523
+
546
524
  // Add download client to all *arr apps
547
525
  const servarrApps = this.config.apps.filter((a) => {
548
526
  const def = getApp(a.id)
@@ -587,31 +565,13 @@ export class AppConfigurator extends BoxRenderable {
587
565
 
588
566
  private async saveCredentialsToEnv(type: "qbittorrent" | "sabnzbd") {
589
567
  try {
590
- const envPath = getComposePath().replace("docker-compose.yml", ".env")
591
-
592
- // Read existing .env if present
593
- const currentEnv: Record<string, string> = {}
594
- if (existsSync(envPath)) {
595
- const content = await readFile(envPath, "utf-8")
596
- content.split("\n").forEach((line) => {
597
- const [key, ...val] = line.split("=")
598
- if (key && val.length > 0) currentEnv[key.trim()] = val.join("=").trim()
599
- })
600
- }
601
-
602
- // Add credentials
568
+ const updates: Record<string, string> = {}
603
569
  if (type === "qbittorrent" && this.qbPass) {
604
- currentEnv["QBITTORRENT_PASSWORD"] = this.qbPass
570
+ updates.QBITTORRENT_PASSWORD = this.qbPass
605
571
  } else if (type === "sabnzbd" && this.sabApiKey) {
606
- currentEnv["SABNZBD_API_KEY"] = this.sabApiKey
572
+ updates.SABNZBD_API_KEY = this.sabApiKey
607
573
  }
608
-
609
- // Reconstruct .env content
610
- const envContent = Object.entries(currentEnv)
611
- .map(([k, v]) => `${k}=${v}`)
612
- .join("\n")
613
-
614
- await writeFile(envPath, envContent, "utf-8")
574
+ await updateEnv(updates)
615
575
  } catch {
616
576
  // Ignore errors - not critical
617
577
  }
@@ -11,6 +11,8 @@ import { createPageLayout } from "../components/PageLayout"
11
11
  import { saveCompose } from "../../compose"
12
12
  import { ApiKeyViewer } from "./ApiKeyViewer"
13
13
  import { AppConfigurator } from "./AppConfigurator"
14
+ import { TRaSHProfileSetup } from "./TRaSHProfileSetup"
15
+ import { ProwlarrSetup } from "./ProwlarrSetup"
14
16
 
15
17
  export class MainMenu {
16
18
  private renderer: RenderContext
@@ -100,10 +102,18 @@ export class MainMenu {
100
102
  name: "⚙️ Configure Apps",
101
103
  description: "Set root folders and download clients via API",
102
104
  },
105
+ {
106
+ name: "🎯 TRaSH Guide Setup",
107
+ description: "Apply TRaSH quality profiles and custom formats",
108
+ },
103
109
  {
104
110
  name: "🔄 Regenerate Compose",
105
111
  description: "Rebuild docker-compose.yml",
106
112
  },
113
+ {
114
+ name: "🔗 Prowlarr Setup",
115
+ description: "Sync indexers to *arr apps, FlareSolverr",
116
+ },
107
117
  { name: "❌ Exit", description: "Close easiarr" },
108
118
  ],
109
119
  })
@@ -143,11 +153,33 @@ export class MainMenu {
143
153
  break
144
154
  }
145
155
  case 5: {
156
+ // TRaSH Profile Setup
157
+ this.menu.blur()
158
+ this.page.visible = false
159
+ const trashSetup = new TRaSHProfileSetup(this.renderer as CliRenderer, this.config, () => {
160
+ this.page.visible = true
161
+ this.menu.focus()
162
+ })
163
+ this.container.add(trashSetup)
164
+ break
165
+ }
166
+ case 6: {
146
167
  // Regenerate compose
147
168
  await saveCompose(this.config)
148
169
  break
149
170
  }
150
- case 6:
171
+ case 7: {
172
+ // Prowlarr Setup
173
+ this.menu.blur()
174
+ this.page.visible = false
175
+ const prowlarrSetup = new ProwlarrSetup(this.renderer as CliRenderer, this.config, () => {
176
+ this.page.visible = true
177
+ this.menu.focus()
178
+ })
179
+ this.container.add(prowlarrSetup)
180
+ break
181
+ }
182
+ case 8:
151
183
  process.exit(0)
152
184
  break
153
185
  }