@muhammedaksam/easiarr 0.2.0 → 0.3.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.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Quality Profile API Client
3
+ * Manages Quality Profiles and Custom Format scoring for Radarr/Sonarr
4
+ */
5
+
6
+ export interface QualityItem {
7
+ id?: number
8
+ name?: string
9
+ quality?: {
10
+ id: number
11
+ name: string
12
+ source: string
13
+ resolution: number
14
+ }
15
+ items?: QualityItem[]
16
+ allowed: boolean
17
+ }
18
+
19
+ export interface FormatItem {
20
+ format: number
21
+ name?: string
22
+ score: number
23
+ }
24
+
25
+ export interface QualityProfile {
26
+ id?: number
27
+ name: string
28
+ upgradeAllowed: boolean
29
+ cutoff: number
30
+ minFormatScore: number
31
+ cutoffFormatScore: number
32
+ formatItems: FormatItem[]
33
+ language?: { id: number; name: string }
34
+ items: QualityItem[]
35
+ }
36
+
37
+ export interface QualityDefinition {
38
+ id: number
39
+ quality: {
40
+ id: number
41
+ name: string
42
+ source: string
43
+ resolution: number
44
+ }
45
+ title: string
46
+ weight: number
47
+ minSize: number
48
+ maxSize: number
49
+ preferredSize: number
50
+ }
51
+
52
+ export class QualityProfileClient {
53
+ private baseUrl: string
54
+ private apiKey: string
55
+ private apiVersion: string
56
+
57
+ constructor(host: string, port: number, apiKey: string, apiVersion = "v3") {
58
+ this.baseUrl = `http://${host}:${port}/api/${apiVersion}`
59
+ this.apiKey = apiKey
60
+ this.apiVersion = apiVersion
61
+ }
62
+
63
+ private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
64
+ const url = `${this.baseUrl}${endpoint}`
65
+ const headers: Record<string, string> = {
66
+ "X-Api-Key": this.apiKey,
67
+ "Content-Type": "application/json",
68
+ ...((options.headers as Record<string, string>) || {}),
69
+ }
70
+
71
+ const response = await fetch(url, { ...options, headers })
72
+
73
+ if (!response.ok) {
74
+ throw new Error(`API request failed: ${response.status} ${response.statusText}`)
75
+ }
76
+
77
+ const text = await response.text()
78
+ if (!text) return {} as T
79
+ return JSON.parse(text) as T
80
+ }
81
+
82
+ // Quality Profile methods
83
+ async getQualityProfiles(): Promise<QualityProfile[]> {
84
+ return this.request<QualityProfile[]>("/qualityprofile")
85
+ }
86
+
87
+ async getQualityProfile(id: number): Promise<QualityProfile> {
88
+ return this.request<QualityProfile>(`/qualityprofile/${id}`)
89
+ }
90
+
91
+ async createQualityProfile(profile: Omit<QualityProfile, "id">): Promise<QualityProfile> {
92
+ return this.request<QualityProfile>("/qualityprofile", {
93
+ method: "POST",
94
+ body: JSON.stringify(profile),
95
+ })
96
+ }
97
+
98
+ async updateQualityProfile(id: number, profile: QualityProfile): Promise<QualityProfile> {
99
+ return this.request<QualityProfile>(`/qualityprofile/${id}`, {
100
+ method: "PUT",
101
+ body: JSON.stringify({ ...profile, id }),
102
+ })
103
+ }
104
+
105
+ async deleteQualityProfile(id: number): Promise<void> {
106
+ await this.request(`/qualityprofile/${id}`, { method: "DELETE" })
107
+ }
108
+
109
+ // Quality Definition methods (for size limits)
110
+ async getQualityDefinitions(): Promise<QualityDefinition[]> {
111
+ return this.request<QualityDefinition[]>("/qualitydefinition")
112
+ }
113
+
114
+ async updateQualityDefinitions(definitions: QualityDefinition[]): Promise<QualityDefinition[]> {
115
+ return this.request<QualityDefinition[]>("/qualitydefinition/update", {
116
+ method: "PUT",
117
+ body: JSON.stringify(definitions),
118
+ })
119
+ }
120
+
121
+ // Helper: Get quality by name from existing profiles
122
+ async getQualityIdByName(name: string): Promise<number | null> {
123
+ const profiles = await this.getQualityProfiles()
124
+ if (profiles.length === 0) return null
125
+
126
+ const findQuality = (items: QualityItem[]): number | null => {
127
+ for (const item of items) {
128
+ if (item.quality?.name === name) return item.quality.id
129
+ if (item.items) {
130
+ const found = findQuality(item.items)
131
+ if (found !== null) return found
132
+ }
133
+ }
134
+ return null
135
+ }
136
+
137
+ return findQuality(profiles[0].items)
138
+ }
139
+
140
+ // Create TRaSH-recommended profile
141
+ async createTRaSHProfile(
142
+ name: string,
143
+ cutoffQualityName: string,
144
+ allowedQualities: string[],
145
+ cfScores: Record<string, number> = {}
146
+ ): Promise<QualityProfile> {
147
+ // Get existing profile to clone quality structure
148
+ const existingProfiles = await this.getQualityProfiles()
149
+ if (existingProfiles.length === 0) {
150
+ throw new Error("No existing profiles to clone quality structure from")
151
+ }
152
+
153
+ const baseProfile = existingProfiles[0]
154
+ const cutoffId = await this.getQualityIdByName(cutoffQualityName)
155
+
156
+ if (cutoffId === null) {
157
+ throw new Error(`Quality "${cutoffQualityName}" not found`)
158
+ }
159
+
160
+ // Build quality items with allowed flags
161
+ const setAllowed = (items: QualityItem[]): QualityItem[] => {
162
+ return items.map((item) => {
163
+ if (item.items) {
164
+ return { ...item, items: setAllowed(item.items) }
165
+ }
166
+ return {
167
+ ...item,
168
+ allowed: item.quality ? allowedQualities.includes(item.quality.name) : false,
169
+ }
170
+ })
171
+ }
172
+
173
+ // Get custom formats and map scores
174
+ const formatItems: FormatItem[] = baseProfile.formatItems.map((fi) => ({
175
+ format: fi.format,
176
+ name: fi.name,
177
+ score: cfScores[fi.name || ""] ?? 0,
178
+ }))
179
+
180
+ const newProfile: Omit<QualityProfile, "id"> = {
181
+ name,
182
+ upgradeAllowed: true,
183
+ cutoff: cutoffId,
184
+ minFormatScore: 0,
185
+ cutoffFormatScore: 10000,
186
+ formatItems,
187
+ language: baseProfile.language,
188
+ items: setAllowed(baseProfile.items),
189
+ }
190
+
191
+ return this.createQualityProfile(newProfile)
192
+ }
193
+
194
+ // Update CF scores on existing profile
195
+ async updateProfileCFScores(profileId: number, cfScores: Record<string, number>): Promise<QualityProfile> {
196
+ const profile = await this.getQualityProfile(profileId)
197
+
198
+ profile.formatItems = profile.formatItems.map((fi) => ({
199
+ ...fi,
200
+ score: cfScores[fi.name || ""] ?? fi.score,
201
+ }))
202
+
203
+ return this.updateQualityProfile(profileId, profile)
204
+ }
205
+ }
@@ -138,10 +138,8 @@ export const APPS: Record<AppId, AppDefinition> = {
138
138
  enabledKey: "api_enabled",
139
139
  generateIfMissing: true,
140
140
  },
141
- rootFolder: {
142
- path: "/data/media/comics",
143
- apiVersion: "v1",
144
- },
141
+ // Note: Mylar3 is NOT an *arr app - has different API format (?cmd=<endpoint>)
142
+ // Root folder is configured via Web UI settings, not API
145
143
  },
146
144
 
147
145
  whisparr: {
@@ -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
+ }
@@ -240,6 +240,28 @@ export class AppConfigurator extends BoxRenderable {
240
240
  this.updateDisplay()
241
241
  }
242
242
 
243
+ // Setup auth for *arr apps without root folders (e.g., Prowlarr)
244
+ if (this.globalPassword) {
245
+ const arrAppsNeedingAuth = ["prowlarr"]
246
+ for (const appId of arrAppsNeedingAuth) {
247
+ const appConfig = this.config.apps.find((a) => a.id === appId && a.enabled)
248
+ if (!appConfig) continue
249
+
250
+ const apiKey = this.extractApiKey(appId as AppId)
251
+ if (!apiKey) continue
252
+
253
+ const appDef = getApp(appId as AppId)
254
+ const port = appConfig.port || appDef?.defaultPort || 9696
255
+ const client = new ArrApiClient("localhost", port, apiKey)
256
+
257
+ try {
258
+ await client.updateHostConfig(this.globalUsername, this.globalPassword, this.overrideExisting)
259
+ } catch {
260
+ // Auth setup for these apps is best-effort
261
+ }
262
+ }
263
+ }
264
+
243
265
  // After root folders, prompt for download clients if needed
244
266
  if (this.hasQBittorrent || this.hasSABnzbd) {
245
267
  if (this.hasQBittorrent) {
@@ -291,8 +313,18 @@ export class AppConfigurator extends BoxRenderable {
291
313
  throw new Error("Already configured")
292
314
  }
293
315
 
294
- // Add root folder
295
- await client.addRootFolder(appDef.rootFolder.path)
316
+ // Add root folder - Lidarr requires profile IDs
317
+ if (appId === "lidarr") {
318
+ const metadataProfiles = await client.getMetadataProfiles()
319
+ const qualityProfiles = await client.getQualityProfiles()
320
+ await client.addRootFolder({
321
+ path: appDef.rootFolder.path,
322
+ defaultMetadataProfileId: metadataProfiles[0]?.id || 1,
323
+ defaultQualityProfileId: qualityProfiles[0]?.id || 1,
324
+ })
325
+ } else {
326
+ await client.addRootFolder(appDef.rootFolder.path)
327
+ }
296
328
 
297
329
  // Set up authentication if credentials provided
298
330
  if (this.globalPassword) {
@@ -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
  }