@muhammedaksam/easiarr 1.1.1 → 1.1.2

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": "1.1.1",
3
+ "version": "1.1.2",
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",
@@ -46,6 +46,7 @@ export interface RemotePathMapping {
46
46
 
47
47
  import type { AppId } from "../config/schema"
48
48
  import { getCategoryForApp, getCategoryFieldName } from "../utils/categories"
49
+ import { TRASH_NAMING_CONFIG, type NamingConfig } from "./naming-config"
49
50
 
50
51
  // qBittorrent download client config
51
52
  export function createQBittorrentConfig(
@@ -239,11 +240,63 @@ export class ArrApiClient {
239
240
  })
240
241
  }
241
242
 
243
+ /**
244
+ * Set application URL for external access (e.g., from Jellyseerr/dashboard links)
245
+ * URL will be used when generating external links in the app
246
+ */
247
+ async setApplicationUrl(applicationUrl: string): Promise<HostConfig> {
248
+ const currentConfig = await this.getHostConfig()
249
+
250
+ const updatedConfig: HostConfig = {
251
+ ...currentConfig,
252
+ applicationUrl,
253
+ }
254
+
255
+ debugLog("ArrAPI", `Setting applicationUrl to: ${applicationUrl}`)
256
+
257
+ return this.request<HostConfig>("/config/host", {
258
+ method: "PUT",
259
+ body: JSON.stringify(updatedConfig),
260
+ })
261
+ }
262
+
242
263
  // Remote Path Mapping methods - for Docker path translation
264
+
243
265
  async getRemotePathMappings(): Promise<RemotePathMapping[]> {
244
266
  return this.request<RemotePathMapping[]>("/remotepathmapping")
245
267
  }
246
268
 
269
+ // Naming Configuration methods
270
+ async getNamingConfig<T extends NamingConfig>(): Promise<T> {
271
+ return this.request<T>("/config/naming")
272
+ }
273
+
274
+ async updateNamingConfig<T extends NamingConfig>(config: T): Promise<T> {
275
+ return this.request<T>("/config/naming", {
276
+ method: "PUT",
277
+ body: JSON.stringify(config),
278
+ })
279
+ }
280
+
281
+ async configureTRaSHNaming(appType: "radarr" | "sonarr"): Promise<void> {
282
+ try {
283
+ // 1. Get current configuration to preserve ID and other fields
284
+ const currentConfig = await this.getNamingConfig<NamingConfig & { id?: number }>()
285
+
286
+ // 2. Merge with TRaSH defaults
287
+ const trashConfig = TRASH_NAMING_CONFIG[appType]
288
+ const newConfig = {
289
+ ...currentConfig,
290
+ ...trashConfig,
291
+ }
292
+
293
+ // 3. Update configuration
294
+ await this.updateNamingConfig(newConfig)
295
+ } catch (e) {
296
+ throw new Error(`Failed to configure naming: ${e}`)
297
+ }
298
+ }
299
+
247
300
  async addRemotePathMapping(host: string, remotePath: string, localPath: string): Promise<RemotePathMapping> {
248
301
  return this.request<RemotePathMapping>("/remotepathmapping", {
249
302
  method: "POST",
@@ -6,6 +6,19 @@
6
6
  import { debugLog } from "../utils/debug"
7
7
  import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
8
8
 
9
+ /**
10
+ * Bazarr Language Profile Structure
11
+ */
12
+ export interface BazarrLanguageProfile {
13
+ name: string
14
+ cutoff: string
15
+ languages: {
16
+ code: string
17
+ forced: boolean
18
+ hi: boolean
19
+ }[]
20
+ }
21
+
9
22
  /**
10
23
  * Bazarr System Settings (partial - auth related fields)
11
24
  */
@@ -232,6 +245,76 @@ export class BazarrApiClient implements IAutoSetupClient {
232
245
  }
233
246
  }
234
247
 
248
+ /**
249
+ * Configure General Settings (TRaSH Recommended)
250
+ */
251
+ async configureGeneralSettings(): Promise<boolean> {
252
+ try {
253
+ debugLog("Bazarr", "Configuring general settings")
254
+ await this.postForm("/system/settings", {
255
+ "settings-subtitles-use_embedded_subtitles": "true",
256
+ "settings-subtitles-autosearch": "true",
257
+ "settings-subtitles-path_mapping": "", // Empty = Alongside Media File
258
+ })
259
+ return true
260
+ } catch (e) {
261
+ debugLog("Bazarr", `Failed to configure general settings: ${e}`)
262
+ return false
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Get all language profiles
268
+ */
269
+ async getLanguageProfiles(): Promise<BazarrLanguageProfile[]> {
270
+ return this.get<BazarrLanguageProfile[]>("/system/languages/profiles")
271
+ }
272
+
273
+ /**
274
+ * Configure Default Language Profile
275
+ * Creates an english profile if it doesn't exist
276
+ */
277
+ async configureDefaultLanguageProfile(name = "English", language = "en"): Promise<boolean> {
278
+ try {
279
+ debugLog("Bazarr", `Configuring language profile: ${name}`)
280
+
281
+ // Get existing profiles to check and to preserve them
282
+ const profiles = (await this.getLanguageProfiles()) || []
283
+ const existing = profiles.find((p) => p.name === name)
284
+
285
+ if (existing) {
286
+ debugLog("Bazarr", `Language profile '${name}' already exists`)
287
+ return true
288
+ }
289
+
290
+ const newProfile: BazarrLanguageProfile = {
291
+ name: name,
292
+ cutoff: language,
293
+ languages: [
294
+ {
295
+ code: language,
296
+ forced: false,
297
+ hi: false,
298
+ },
299
+ ],
300
+ }
301
+
302
+ profiles.push(newProfile)
303
+
304
+ // Update settings with the new list (serialized as JSON)
305
+ // Note: Bazarr expects 'languages_profiles' as a JSON string in the form data
306
+ await this.postForm("/system/settings", {
307
+ languages_profiles: JSON.stringify(profiles),
308
+ })
309
+
310
+ debugLog("Bazarr", `Created language profile: ${name}`)
311
+ return true
312
+ } catch (e) {
313
+ debugLog("Bazarr", `Failed to configure language profile: ${e}`)
314
+ return false
315
+ }
316
+ }
317
+
235
318
  /**
236
319
  * Run the auto-setup process for Bazarr
237
320
  */
@@ -237,6 +237,15 @@ export class JellyseerrClient implements IAutoSetupClient {
237
237
  })
238
238
  }
239
239
 
240
+ /**
241
+ * Set application URL for external access
242
+ * URL will be used for links to Jellyseerr from other apps
243
+ */
244
+ async setApplicationUrl(applicationUrl: string): Promise<JellyseerrMainSettings> {
245
+ debugLog("Jellyseerr", `Setting applicationUrl to: ${applicationUrl}`)
246
+ return this.updateMainSettings({ applicationUrl })
247
+ }
248
+
240
249
  // ==========================================
241
250
  // Jellyfin Configuration
242
251
  // ==========================================
@@ -383,6 +392,13 @@ export class JellyseerrClient implements IAutoSetupClient {
383
392
  })
384
393
  }
385
394
 
395
+ async updateRadarr(id: number, settings: Partial<JellyseerrRadarrSettings>): Promise<JellyseerrRadarrSettings> {
396
+ return this.request<JellyseerrRadarrSettings>(`/settings/radarr/${id}`, {
397
+ method: "PUT",
398
+ body: JSON.stringify(settings),
399
+ })
400
+ }
401
+
386
402
  // ==========================================
387
403
  // Sonarr Configuration
388
404
  // ==========================================
@@ -411,6 +427,13 @@ export class JellyseerrClient implements IAutoSetupClient {
411
427
  })
412
428
  }
413
429
 
430
+ async updateSonarr(id: number, settings: Partial<JellyseerrSonarrSettings>): Promise<JellyseerrSonarrSettings> {
431
+ return this.request<JellyseerrSonarrSettings>(`/settings/sonarr/${id}`, {
432
+ method: "PUT",
433
+ body: JSON.stringify(settings),
434
+ })
435
+ }
436
+
414
437
  // ==========================================
415
438
  // Full Setup Wizard
416
439
  // ==========================================
@@ -453,14 +476,32 @@ export class JellyseerrClient implements IAutoSetupClient {
453
476
 
454
477
  /**
455
478
  * Configure Radarr connection with auto-detection of profiles
479
+ * @param externalUrl - Optional external URL for navigation (e.g., https://radarr.example.com)
456
480
  */
457
481
  async configureRadarr(
458
482
  hostname: string,
459
483
  port: number,
460
484
  apiKey: string,
461
- rootFolder: string
485
+ rootFolder: string,
486
+ externalUrl?: string
462
487
  ): Promise<JellyseerrRadarrSettings | null> {
463
488
  try {
489
+ // Check if Radarr is already configured
490
+ const existingConfigs = await this.getRadarrSettings()
491
+ const existingConfig = existingConfigs.find((c) => c.hostname === hostname && c.port === port)
492
+
493
+ if (existingConfig?.id) {
494
+ // Update existing configuration (just update externalUrl)
495
+ // Note: id must be excluded from body - it's read-only in the API
496
+ debugLog("Jellyseerr", `Updating existing Radarr config (id: ${existingConfig.id})`)
497
+ const { id, ...configWithoutId } = existingConfig
498
+ return await this.updateRadarr(id, {
499
+ ...configWithoutId,
500
+ externalUrl,
501
+ })
502
+ }
503
+
504
+ // Test connection and get profiles
464
505
  const testResult = await this.testRadarr({
465
506
  hostname,
466
507
  port,
@@ -487,6 +528,7 @@ export class JellyseerrClient implements IAutoSetupClient {
487
528
  is4k: false,
488
529
  minimumAvailability: "announced",
489
530
  isDefault: true,
531
+ externalUrl,
490
532
  })
491
533
  } catch (e) {
492
534
  debugLog("Jellyseerr", `Radarr config failed: ${e}`)
@@ -496,14 +538,32 @@ export class JellyseerrClient implements IAutoSetupClient {
496
538
 
497
539
  /**
498
540
  * Configure Sonarr connection with auto-detection of profiles
541
+ * @param externalUrl - Optional external URL for navigation (e.g., https://sonarr.example.com)
499
542
  */
500
543
  async configureSonarr(
501
544
  hostname: string,
502
545
  port: number,
503
546
  apiKey: string,
504
- rootFolder: string
547
+ rootFolder: string,
548
+ externalUrl?: string
505
549
  ): Promise<JellyseerrSonarrSettings | null> {
506
550
  try {
551
+ // Check if Sonarr is already configured
552
+ const existingConfigs = await this.getSonarrSettings()
553
+ const existingConfig = existingConfigs.find((c) => c.hostname === hostname && c.port === port)
554
+
555
+ if (existingConfig?.id) {
556
+ // Update existing configuration (just update externalUrl)
557
+ // Note: id must be excluded from body - it's read-only in the API
558
+ debugLog("Jellyseerr", `Updating existing Sonarr config (id: ${existingConfig.id})`)
559
+ const { id, ...configWithoutId } = existingConfig
560
+ return await this.updateSonarr(id, {
561
+ ...configWithoutId,
562
+ externalUrl,
563
+ })
564
+ }
565
+
566
+ // Test connection and get profiles
507
567
  const testResult = await this.testSonarr({
508
568
  hostname,
509
569
  port,
@@ -530,6 +590,7 @@ export class JellyseerrClient implements IAutoSetupClient {
530
590
  is4k: false,
531
591
  enableSeasonFolders: true,
532
592
  isDefault: true,
593
+ externalUrl,
533
594
  })
534
595
  } catch (e) {
535
596
  debugLog("Jellyseerr", `Sonarr config failed: ${e}`)
@@ -550,10 +611,27 @@ export class JellyseerrClient implements IAutoSetupClient {
550
611
  return { success: false, message: "Jellyseerr not reachable" }
551
612
  }
552
613
 
614
+ // Get Jellyfin connection details from env
615
+ const jellyfinHost = env["JELLYFIN_HOST"] || "jellyfin"
616
+ const jellyfinPort = parseInt(env["JELLYFIN_PORT"] || "8096", 10)
617
+
553
618
  // Check if already initialized
554
619
  const initialized = await this.isInitialized()
555
620
  if (initialized) {
556
- // Get API key from settings
621
+ // Authenticate first to get session cookie (needed for protected endpoints)
622
+ try {
623
+ await this.authenticateJellyfin(username, password, jellyfinHost, jellyfinPort)
624
+ debugLog("Jellyseerr", "Authenticated to already-initialized instance")
625
+ } catch (authError) {
626
+ debugLog("Jellyseerr", `Auth failed on initialized instance: ${authError}`)
627
+ // Still return success, just without API key access
628
+ return {
629
+ success: true,
630
+ message: "Already configured (could not authenticate)",
631
+ }
632
+ }
633
+
634
+ // Now we can access protected endpoints with our session cookie
557
635
  const settings = await this.getMainSettings()
558
636
  return {
559
637
  success: true,
@@ -563,11 +641,7 @@ export class JellyseerrClient implements IAutoSetupClient {
563
641
  }
564
642
  }
565
643
 
566
- // Get Jellyfin connection details from env
567
- const jellyfinHost = env["JELLYFIN_HOST"] || "jellyfin"
568
- const jellyfinPort = parseInt(env["JELLYFIN_PORT"] || "8096", 10)
569
-
570
- // Run the setup wizard
644
+ // Run the setup wizard (authenticates as part of setup)
571
645
  const apiKey = await this.runJellyfinSetup(jellyfinHost, jellyfinPort, username, password)
572
646
 
573
647
  // Mark as initialized
@@ -0,0 +1,67 @@
1
+ export interface RadarrNamingConfig {
2
+ renameMovies: boolean
3
+ replaceIllegalCharacters: boolean
4
+ colonReplacementFormat: "dash" | "spaceDash" | "spaceDashSpace" | "smart" | "delete" | number
5
+ standardMovieFormat: string
6
+ movieFolderFormat: string
7
+ includeQuality: boolean
8
+ replaceSpaces: boolean
9
+ }
10
+
11
+ export interface SonarrNamingConfig {
12
+ renameEpisodes: boolean
13
+ replaceIllegalCharacters: boolean
14
+ colonReplacementFormat: "dash" | "spaceDash" | "spaceDashSpace" | "smart" | "delete" | number
15
+ multiEpisodeStyle: "extend" | "duplicate" | "repeat" | "scene" | "range" | "prefixedRange" | number
16
+ dailyEpisodeFormat: string
17
+ animeEpisodeFormat: string
18
+ seriesFolderFormat: string
19
+ seasonFolderFormat: string
20
+ standardEpisodeFormat: string
21
+ includeSeriesTitle: boolean
22
+ includeEpisodeTitle: boolean
23
+ includeQuality: boolean
24
+ replaceSpaces: boolean
25
+ separator: string
26
+ numberStyle: string
27
+ }
28
+
29
+ export type NamingConfig = RadarrNamingConfig | SonarrNamingConfig
30
+
31
+ // TRaSH Guides Recommended Naming Schemes
32
+ // Source: https://trash-guides.info/Radarr/Radarr-recommended-naming-scheme/
33
+ // Source: https://trash-guides.info/Sonarr/Sonarr-recommended-naming-scheme/
34
+
35
+ export const TRASH_NAMING_CONFIG = {
36
+ radarr: {
37
+ renameMovies: true,
38
+ replaceIllegalCharacters: true,
39
+ colonReplacementFormat: "dash",
40
+ standardMovieFormat:
41
+ "{Movie CleanTitle} ({Release Year}) {edition-{Edition Tags}} {[Custom Formats]}{[Quality Full]}{[MediaInfo 3D]}{[Mediainfo AudioCodec}{ Mediainfo AudioChannels]}{[MediaInfo VideoDynamicRangeType]}{[Mediainfo VideoCodec]}{-Release Group}",
42
+ movieFolderFormat: "{Movie CleanTitle} ({Release Year})",
43
+ includeQuality: true,
44
+ replaceSpaces: false,
45
+ } as RadarrNamingConfig,
46
+
47
+ sonarr: {
48
+ renameEpisodes: true,
49
+ replaceIllegalCharacters: true,
50
+ colonReplacementFormat: 1, // 1 = Dash
51
+ multiEpisodeStyle: 5, // 5 = Prefixed Range
52
+ standardEpisodeFormat:
53
+ "{Series TitleYear} - S{season:00}E{episode:00} - {Episode CleanTitle} {[Custom Formats]}{[Quality Full]}{[Mediainfo AudioCodec}{ Mediainfo AudioChannels]}{[MediaInfo VideoDynamicRangeType]}{[Mediainfo VideoCodec]}{-Release Group}",
54
+ dailyEpisodeFormat:
55
+ "{Series TitleYear} - {Air-Date} - {Episode CleanTitle} {[Custom Formats]}{[Quality Full]}{[MediaInfo 3D]}{[Mediainfo AudioCodec}{ Mediainfo AudioChannels]}{[MediaInfo VideoDynamicRangeType]}{[Mediainfo VideoCodec]}{-Release Group}",
56
+ animeEpisodeFormat:
57
+ "{Series TitleYear} - S{season:00}E{episode:00} - {absolute:000} - {Episode CleanTitle} {[Custom Formats]}{[Quality Full]}{[MediaInfo 3D]}{[Mediainfo AudioCodec}{ Mediainfo AudioChannels]}{MediaInfo AudioLanguages}{[MediaInfo VideoDynamicRangeType]}[{Mediainfo VideoCodec }{MediaInfo VideoBitDepth}bit]{-Release Group}",
58
+ seriesFolderFormat: "{Series TitleYear}",
59
+ seasonFolderFormat: "Season {season:00}",
60
+ includeSeriesTitle: true,
61
+ includeEpisodeTitle: true,
62
+ includeQuality: true,
63
+ replaceSpaces: false,
64
+ separator: " - ",
65
+ numberStyle: "S{season:00}E{episode:00}",
66
+ } as SonarrNamingConfig,
67
+ }
@@ -254,6 +254,30 @@ export class OverseerrClient implements IAutoSetupClient {
254
254
  return null
255
255
  }
256
256
 
257
+ /**
258
+ * Set application URL for external access
259
+ * URL will be used for links to Overseerr from other apps
260
+ */
261
+ async setApplicationUrl(applicationUrl: string): Promise<boolean> {
262
+ debugLog("OverseerrApi", `Setting applicationUrl to: ${applicationUrl}`)
263
+
264
+ try {
265
+ const response = await fetch(`${this.baseUrl}/api/v1/settings/main`, {
266
+ method: "POST",
267
+ headers: this.getHeaders(),
268
+ body: JSON.stringify({ applicationUrl }),
269
+ })
270
+
271
+ if (response.ok) {
272
+ debugLog("OverseerrApi", "Application URL set successfully")
273
+ return true
274
+ }
275
+ } catch (error) {
276
+ debugLog("OverseerrApi", `Failed to set application URL: ${error}`)
277
+ }
278
+ return false
279
+ }
280
+
257
281
  /**
258
282
  * Sync Plex libraries
259
283
  */
@@ -14,6 +14,33 @@ export interface QBittorrentPreferences {
14
14
  auto_tmm_enabled?: boolean
15
15
  category_changed_tmm_enabled?: boolean
16
16
  save_path_changed_tmm_enabled?: boolean
17
+ max_ratio?: number
18
+ max_ratio_enabled?: boolean
19
+ max_ratio_act?: number // 0 = Pause, 1 = Remove
20
+ max_seeding_time?: number
21
+ max_seeding_time_enabled?: boolean
22
+ queueing_enabled?: boolean
23
+ web_ui_username?: string
24
+ web_ui_password?: string
25
+ // Connection & Speed
26
+ listen_port?: number
27
+ upnp?: boolean
28
+ natpmp?: boolean
29
+ dl_limit?: number
30
+ up_limit?: number
31
+ limit_utp_rate?: boolean
32
+ limit_tcp_overhead?: boolean
33
+ limit_lan_peers?: boolean
34
+ // Bittorrent
35
+ enable_dht?: boolean
36
+ enable_pex?: boolean
37
+ enable_lsd?: boolean
38
+ encryption_mode?: number // 0=Prefer, 1=Force, 2=Allow (check API docs for exact enum, typically 1=Force or Allow)
39
+ anonymous_mode?: boolean
40
+ add_trackers_enabled?: boolean
41
+ pre_allocate_all?: boolean
42
+ incomplete_files_ext?: boolean
43
+ create_subfolder_enabled?: boolean
17
44
  }
18
45
 
19
46
  export interface QBittorrentCategory {
@@ -197,6 +224,37 @@ export class QBittorrentClient implements IAutoSetupClient {
197
224
  auto_tmm_enabled: true,
198
225
  category_changed_tmm_enabled: true,
199
226
  save_path_changed_tmm_enabled: true,
227
+
228
+ // Downloads
229
+ pre_allocate_all: false, // Recommended disabled for unRaid/cache drives, safe default
230
+ incomplete_files_ext: true, // Recommended: Enabled
231
+ create_subfolder_enabled: true, // Recommended: Enabled
232
+
233
+ // Connection
234
+ upnp: false, // Recommended: Disabled (use manual port forward)
235
+ natpmp: false, // Recommended: Disabled
236
+
237
+ // Speed
238
+ dl_limit: -1, // Unlimited
239
+ up_limit: -1, // Unlimited (User should tune if needed)
240
+ limit_utp_rate: true, // Recommended: Enabled
241
+ limit_tcp_overhead: false, // Recommended: Disabled
242
+ limit_lan_peers: true, // Recommended: Enabled
243
+
244
+ // Bittorrent
245
+ enable_dht: true, // Implied enabled
246
+ enable_pex: true, // Implied enabled
247
+ enable_lsd: true, // Implied enabled
248
+ encryption_mode: 0, // 0 = Prefer Encryption (closest to "Allow encryption" usually, need to verify enum. 1=Force, 2=None usually. Let's assume 0 is safe/default or check docs if possible. TRaSH says "Allow encryption")
249
+ anonymous_mode: false, // Recommended: Disabled
250
+ add_trackers_enabled: false, // Recommended: Disabled for private trackers
251
+ queueing_enabled: true, // Recommended: Personal preference, enabled by default
252
+
253
+ // Seeding Limits (TRaSH Recommended: Disable globally)
254
+ max_ratio: -1, // -1 = Unlimited
255
+ max_ratio_enabled: false,
256
+ max_seeding_time_enabled: false,
257
+ max_ratio_act: 0, // 0 = Pause (Safe fallback)
200
258
  }
201
259
 
202
260
  if (auth) {
@@ -3,6 +3,9 @@
3
3
  * Manages Quality Profiles and Custom Format scoring for Radarr/Sonarr
4
4
  */
5
5
 
6
+ import { debugLog } from "../utils/debug"
7
+ import { TRASH_RADARR_QUALITY_DEFINITIONS, TRASH_SONARR_QUALITY_DEFINITIONS } from "../config/trash-quality-definitions"
8
+
6
9
  export interface QualityItem {
7
10
  id?: number
8
11
  name?: string
@@ -112,10 +115,23 @@ export class QualityProfileClient {
112
115
  }
113
116
 
114
117
  async updateQualityDefinitions(definitions: QualityDefinition[]): Promise<QualityDefinition[]> {
115
- return this.request<QualityDefinition[]>("/qualitydefinition/update", {
116
- method: "PUT",
117
- body: JSON.stringify(definitions),
118
- })
118
+ const updated: QualityDefinition[] = []
119
+
120
+ // Update each definition individually as bulk update is not reliably supported across all versions
121
+ for (const def of definitions) {
122
+ if (!def.id) continue
123
+
124
+ try {
125
+ const result = await this.request<QualityDefinition>(`/qualitydefinition/${def.id}`, {
126
+ method: "PUT",
127
+ body: JSON.stringify(def),
128
+ })
129
+ updated.push(result)
130
+ } catch (e) {
131
+ debugLog("QualityProfile", `Failed to update definition ${def.id}: ${e}`)
132
+ }
133
+ }
134
+ return updated
119
135
  }
120
136
 
121
137
  // Helper: Get quality by name from existing profiles
@@ -202,4 +218,38 @@ export class QualityProfileClient {
202
218
 
203
219
  return this.updateQualityProfile(profileId, profile)
204
220
  }
221
+
222
+ // Apply TRaSH Quality Definitions (File Size Limits)
223
+ async updateTrashQualityDefinitions(appType: "radarr" | "sonarr"): Promise<void> {
224
+ try {
225
+ debugLog("QualityProfile", `Updating Quality Definitions for ${appType}`)
226
+ const definitions = await this.getQualityDefinitions()
227
+ const trashDefs = appType === "radarr" ? TRASH_RADARR_QUALITY_DEFINITIONS : TRASH_SONARR_QUALITY_DEFINITIONS
228
+ let updatedCount = 0
229
+
230
+ const newDefinitions = definitions.map((def) => {
231
+ const trashDef = trashDefs.find((t) => t.quality === def.quality.name)
232
+ if (trashDef) {
233
+ updatedCount++
234
+ return {
235
+ ...def,
236
+ minSize: trashDef.min,
237
+ maxSize: trashDef.max,
238
+ preferredSize: trashDef.preferred,
239
+ }
240
+ }
241
+ return def
242
+ })
243
+
244
+ if (updatedCount > 0) {
245
+ await this.updateQualityDefinitions(newDefinitions)
246
+ debugLog("QualityProfile", `Successfully updated ${updatedCount} quality definitions`)
247
+ } else {
248
+ debugLog("QualityProfile", "No matching quality definitions found to update")
249
+ }
250
+ } catch (e) {
251
+ debugLog("QualityProfile", `Failed to update quality definitions: ${e}`)
252
+ throw e
253
+ }
254
+ }
205
255
  }
@@ -248,6 +248,17 @@ export const APPS: Record<AppId, AppDefinition> = {
248
248
  LOG_HTML: "false",
249
249
  CAPTCHA_SOLVER: "none",
250
250
  },
251
+ homepage: {
252
+ icon: "flaresolverr.png",
253
+ widget: "customapi",
254
+ widgetFields: {
255
+ url: "http://flaresolverr:8191",
256
+ mappings: JSON.stringify([
257
+ { field: "msg", label: "Status" },
258
+ { field: "version", label: "Version" },
259
+ ]),
260
+ },
261
+ },
251
262
  },
252
263
 
253
264
  // === DOWNLOAD CLIENTS ===
@@ -647,7 +658,10 @@ export const APPS: Record<AppId, AppDefinition> = {
647
658
  widget: "customapi",
648
659
  widgetFields: {
649
660
  url: "http://easiarr:8080/config.json",
650
- mappings: JSON.stringify([{ field: "version", label: "Installed" }]),
661
+ mappings: JSON.stringify([
662
+ { field: "version", label: "Version" },
663
+ { field: "apps.length", label: "Apps" },
664
+ ]),
651
665
  },
652
666
  },
653
667
  },
@@ -24,7 +24,7 @@ export function getHomepageConfigPath(config: EasiarrConfig): string {
24
24
  * Homepage service entry
25
25
  */
26
26
  interface HomepageService {
27
- href: string
27
+ href?: string // Optional - cloudflared has no web UI
28
28
  icon?: string
29
29
  description?: string
30
30
  ping?: string
@@ -63,10 +63,22 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
63
63
  const dockerUrl = `http://${appDef.id}:${internalPort}`
64
64
 
65
65
  const service: HomepageService = {
66
- href: baseUrl,
67
66
  description: appDef.description,
68
67
  }
69
68
 
69
+ // Special cases for href/ping
70
+ if (appDef.id === "cloudflared") {
71
+ // Cloudflared has no web UI - skip href/ping
72
+ } else if (appDef.id === "traefik") {
73
+ // Traefik dashboard is on port 8083 (secondary port), not 80 (proxy)
74
+ const dashboardUrl = `http://${localIp}:8083`
75
+ service.href = dashboardUrl
76
+ service.ping = dashboardUrl
77
+ } else {
78
+ service.href = baseUrl
79
+ service.ping = baseUrl
80
+ }
81
+
70
82
  // Add icon if defined in homepage meta
71
83
  if (appDef.homepage?.icon) {
72
84
  service.icon = appDef.homepage.icon
@@ -75,9 +87,6 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
75
87
  service.icon = `${appDef.id}.png`
76
88
  }
77
89
 
78
- // Add ping for monitoring
79
- service.ping = baseUrl
80
-
81
90
  // Add widget if defined
82
91
  if (appDef.homepage?.widget) {
83
92
  const apiKey = env[`API_KEY_${appDef.id.toUpperCase()}`]
@@ -114,7 +123,10 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
114
123
  // Skip widget if no API key
115
124
  }
116
125
  // Most widgets need API key - only add if available
117
- else if (apiKey || ["qbittorrent", "gluetun", "traefik", "huntarr"].includes(appDef.id)) {
126
+ else if (
127
+ apiKey ||
128
+ ["qbittorrent", "gluetun", "traefik", "huntarr", "easiarr", "flaresolverr"].includes(appDef.id)
129
+ ) {
118
130
  service.widget = {
119
131
  type: widgetType,
120
132
  url: dockerUrl,
@@ -236,7 +248,10 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
236
248
 
237
249
  for (const { name, service } of services) {
238
250
  yaml += ` - ${name}:\n`
239
- yaml += ` href: ${service.href}\n`
251
+
252
+ if (service.href) {
253
+ yaml += ` href: ${service.href}\n`
254
+ }
240
255
 
241
256
  if (service.icon) {
242
257
  yaml += ` icon: ${service.icon}\n`
@@ -253,7 +268,9 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
253
268
  if (service.widget) {
254
269
  yaml += ` widget:\n`
255
270
  yaml += ` type: ${service.widget.type}\n`
256
- yaml += ` url: ${service.widget.url}\n`
271
+ if (service.widget.url) {
272
+ yaml += ` url: ${service.widget.url}\n`
273
+ }
257
274
 
258
275
  if (service.widget.key) {
259
276
  yaml += ` key: ${service.widget.key}\n`
@@ -0,0 +1,187 @@
1
+ /**
2
+ * TRaSH Guides Quality Definitions (File Size Limits)
3
+ * Min/Preferred/Max in MB/min
4
+ * Source: ../Guides/docs/json/radarr/quality-size/movie.json
5
+ * Source: ../Guides/docs/json/sonarr/quality-size/series.json
6
+ */
7
+
8
+ export interface TrashQualityDefinition {
9
+ quality: string
10
+ min: number
11
+ preferred: number
12
+ max: number
13
+ }
14
+
15
+ export const TRASH_RADARR_QUALITY_DEFINITIONS: TrashQualityDefinition[] = [
16
+ {
17
+ quality: "HDTV-720p",
18
+ min: 17.1,
19
+ preferred: 1999,
20
+ max: 2000,
21
+ },
22
+ {
23
+ quality: "WEBDL-720p",
24
+ min: 12.5,
25
+ preferred: 1999,
26
+ max: 2000,
27
+ },
28
+ {
29
+ quality: "WEBRip-720p",
30
+ min: 12.5,
31
+ preferred: 1999,
32
+ max: 2000,
33
+ },
34
+ {
35
+ quality: "Bluray-720p",
36
+ min: 25.7,
37
+ preferred: 1999,
38
+ max: 2000,
39
+ },
40
+ {
41
+ quality: "HDTV-1080p",
42
+ min: 33.8,
43
+ preferred: 1999,
44
+ max: 2000,
45
+ },
46
+ {
47
+ quality: "WEBDL-1080p",
48
+ min: 12.5,
49
+ preferred: 1999,
50
+ max: 2000,
51
+ },
52
+ {
53
+ quality: "WEBRip-1080p",
54
+ min: 12.5,
55
+ preferred: 1999,
56
+ max: 2000,
57
+ },
58
+ {
59
+ quality: "Bluray-1080p",
60
+ min: 50.8,
61
+ preferred: 1999,
62
+ max: 2000,
63
+ },
64
+ {
65
+ quality: "Remux-1080p",
66
+ min: 102,
67
+ preferred: 1999,
68
+ max: 2000,
69
+ },
70
+ {
71
+ quality: "HDTV-2160p",
72
+ min: 85,
73
+ preferred: 1999,
74
+ max: 2000,
75
+ },
76
+ {
77
+ quality: "WEBDL-2160p",
78
+ min: 34.5,
79
+ preferred: 1999,
80
+ max: 2000,
81
+ },
82
+ {
83
+ quality: "WEBRip-2160p",
84
+ min: 34.5,
85
+ preferred: 1999,
86
+ max: 2000,
87
+ },
88
+ {
89
+ quality: "Bluray-2160p",
90
+ min: 102,
91
+ preferred: 1999,
92
+ max: 2000,
93
+ },
94
+ {
95
+ quality: "Remux-2160p",
96
+ min: 187.4,
97
+ preferred: 1999,
98
+ max: 2000,
99
+ },
100
+ ]
101
+
102
+ export const TRASH_SONARR_QUALITY_DEFINITIONS: TrashQualityDefinition[] = [
103
+ {
104
+ quality: "HDTV-720p",
105
+ min: 10,
106
+ preferred: 995,
107
+ max: 1000,
108
+ },
109
+ {
110
+ quality: "HDTV-1080p",
111
+ min: 15,
112
+ preferred: 995,
113
+ max: 1000,
114
+ },
115
+ {
116
+ quality: "WEBRip-720p",
117
+ min: 10,
118
+ preferred: 995,
119
+ max: 1000,
120
+ },
121
+ {
122
+ quality: "WEBDL-720p",
123
+ min: 10,
124
+ preferred: 995,
125
+ max: 1000,
126
+ },
127
+ {
128
+ quality: "Bluray-720p",
129
+ min: 17.1,
130
+ preferred: 995,
131
+ max: 1000,
132
+ },
133
+ {
134
+ quality: "WEBRip-1080p",
135
+ min: 15,
136
+ preferred: 995,
137
+ max: 1000,
138
+ },
139
+ {
140
+ quality: "WEBDL-1080p",
141
+ min: 15,
142
+ preferred: 995,
143
+ max: 1000,
144
+ },
145
+ {
146
+ quality: "Bluray-1080p",
147
+ min: 50.4,
148
+ preferred: 995,
149
+ max: 1000,
150
+ },
151
+ {
152
+ quality: "Bluray-1080p Remux",
153
+ min: 69.1,
154
+ preferred: 995,
155
+ max: 1000,
156
+ },
157
+ {
158
+ quality: "HDTV-2160p",
159
+ min: 25,
160
+ preferred: 995,
161
+ max: 1000,
162
+ },
163
+ {
164
+ quality: "WEBRip-2160p",
165
+ min: 25,
166
+ preferred: 995,
167
+ max: 1000,
168
+ },
169
+ {
170
+ quality: "WEBDL-2160p",
171
+ min: 25,
172
+ preferred: 995,
173
+ max: 1000,
174
+ },
175
+ {
176
+ quality: "Bluray-2160p",
177
+ min: 94.6,
178
+ preferred: 995,
179
+ max: 1000,
180
+ },
181
+ {
182
+ quality: "Bluray-2160p Remux",
183
+ min: 187.4,
184
+ preferred: 995,
185
+ max: 1000,
186
+ },
187
+ ]
@@ -12,6 +12,7 @@ import { ProwlarrClient, type ArrAppType } from "../../api/prowlarr-api"
12
12
  import { QBittorrentClient, type QBittorrentCategory } from "../../api/qbittorrent-api"
13
13
  import { PortainerApiClient } from "../../api/portainer-api"
14
14
  import { JellyfinClient } from "../../api/jellyfin-api"
15
+ import { QualityProfileClient } from "../../api/quality-profile-api"
15
16
  import { JellyseerrClient } from "../../api/jellyseerr-api"
16
17
  import { CloudflareApi, setupCloudflaredTunnel } from "../../api/cloudflare-api"
17
18
  import { PlexApiClient } from "../../api/plex-api"
@@ -29,6 +30,7 @@ import { getApp } from "../../apps/registry"
29
30
  import { getCategoriesForApps } from "../../utils/categories"
30
31
  import { readEnvSync, updateEnv } from "../../utils/env"
31
32
  import { debugLog } from "../../utils/debug"
33
+ import { getApplicationUrl } from "../../utils/url-utils"
32
34
 
33
35
  interface SetupStep {
34
36
  name: string
@@ -90,7 +92,10 @@ export class FullAutoSetup extends BoxRenderable {
90
92
  private initSteps(): void {
91
93
  this.steps = [
92
94
  { name: "Root Folders", status: "pending" },
95
+ { name: "Naming Scheme", status: "pending" },
96
+ { name: "Quality Settings", status: "pending" },
93
97
  { name: "Authentication", status: "pending" },
98
+ { name: "External URLs", status: "pending" },
94
99
  { name: "Prowlarr Apps", status: "pending" },
95
100
  { name: "FlareSolverr", status: "pending" },
96
101
  { name: "qBittorrent", status: "pending" },
@@ -140,55 +145,64 @@ export class FullAutoSetup extends BoxRenderable {
140
145
  // Step 1: Root folders
141
146
  await this.setupRootFolders()
142
147
 
143
- // Step 2: Authentication
148
+ // Step 2: Naming Scheme
149
+ await this.setupNaming()
150
+
151
+ // Step 2b: Quality Settings
152
+ await this.setupQuality()
153
+
154
+ // Step 3: Authentication
144
155
  await this.setupAuthentication()
145
156
 
146
- // Step 3: Prowlarr apps
157
+ // Step 3: External URLs
158
+ await this.setupExternalUrls()
159
+
160
+ // Step 4: Prowlarr apps
147
161
  await this.setupProwlarrApps()
148
162
 
149
- // Step 4: FlareSolverr
163
+ // Step 5: FlareSolverr
150
164
  await this.setupFlareSolverr()
151
165
 
152
- // Step 5: qBittorrent
166
+ // Step 6: qBittorrent
153
167
  await this.setupQBittorrent()
154
168
 
155
- // Step 6: Portainer
169
+ // Step 7: Portainer
156
170
  await this.setupPortainer()
157
171
 
158
- // Step 7: Jellyfin
172
+ // Step 8: Jellyfin
159
173
  await this.setupJellyfin()
160
174
 
161
- // Step 8: Jellyseerr
175
+ // Step 9: Jellyseerr
162
176
  await this.setupJellyseerr()
163
177
 
164
- // Step 9: Plex
178
+ // Step 10: Plex
165
179
  await this.setupPlex()
166
180
 
167
- // Step 10: Overseerr (requires Plex)
181
+ // Step 11: Overseerr (requires Plex)
168
182
  await this.setupOverseerr()
169
183
 
170
- // Step 11: Tautulli (Plex monitoring)
184
+ // Step 12: Tautulli (Plex monitoring)
171
185
  await this.setupTautulli()
172
186
 
173
- // Step 12: Bazarr (subtitles)
187
+ // Step 13: Bazarr (subtitles)
174
188
  await this.setupBazarr()
175
189
 
176
- // Step 13: Uptime Kuma (monitors)
190
+ // Step 14: Uptime Kuma (monitors)
177
191
  await this.setupUptimeKuma()
178
192
 
179
- // Step 14: Grafana (dashboards)
193
+ // Step 15: Grafana (dashboards)
180
194
  await this.setupGrafana()
181
195
 
182
- // Step 15: Homarr (dashboard)
196
+ // Step 16: Homarr (dashboard)
183
197
  await this.setupHomarr()
184
198
 
185
- // Step 16: Heimdall (dashboard)
199
+ // Step 17: Heimdall (dashboard)
186
200
  await this.setupHeimdall()
187
201
 
188
- // Step 17: Huntarr (*arr app manager)
202
+ // Step 18: Huntarr (*arr app manager)
189
203
  await this.setupHuntarr()
190
204
 
191
- // Step 18: Cloudflare Tunnel
205
+ // Step 19: Cloudflare Tunnel
192
206
  await this.setupCloudflare()
193
207
 
194
208
  this.isRunning = false
@@ -235,6 +249,74 @@ export class FullAutoSetup extends BoxRenderable {
235
249
  this.refreshContent()
236
250
  }
237
251
 
252
+ private async setupNaming(): Promise<void> {
253
+ this.updateStep("Naming Scheme", "running")
254
+ this.refreshContent()
255
+
256
+ try {
257
+ const arrApps = this.config.apps.filter((a) => {
258
+ return a.enabled && (a.id === "radarr" || a.id === "sonarr")
259
+ })
260
+
261
+ for (const app of arrApps) {
262
+ const apiKey = this.env[`API_KEY_${app.id.toUpperCase()}`]
263
+ if (!apiKey) continue
264
+
265
+ const def = getApp(app.id)
266
+ if (!def) continue
267
+
268
+ const port = app.port || def.defaultPort
269
+ const client = new ArrApiClient("localhost", port, apiKey, def.rootFolder?.apiVersion || "v3")
270
+
271
+ try {
272
+ await client.configureTRaSHNaming(app.id as "radarr" | "sonarr")
273
+ debugLog("FullAutoSetup", `Configured naming for ${app.id}`)
274
+ } catch (e) {
275
+ debugLog("FullAutoSetup", `Failed to configure naming for ${app.id}: ${e}`)
276
+ }
277
+ }
278
+
279
+ this.updateStep("Naming Scheme", "success")
280
+ } catch (e) {
281
+ this.updateStep("Naming Scheme", "error", `${e}`)
282
+ }
283
+ this.refreshContent()
284
+ }
285
+
286
+ private async setupQuality(): Promise<void> {
287
+ this.updateStep("Quality Settings", "running")
288
+ this.refreshContent()
289
+
290
+ try {
291
+ const arrApps = this.config.apps.filter((a) => {
292
+ return a.enabled && (a.id === "radarr" || a.id === "sonarr")
293
+ })
294
+
295
+ for (const app of arrApps) {
296
+ const apiKey = this.env[`API_KEY_${app.id.toUpperCase()}`]
297
+ if (!apiKey) continue
298
+
299
+ const def = getApp(app.id)
300
+ if (!def) continue
301
+
302
+ const port = app.port || def.defaultPort
303
+ const client = new QualityProfileClient("localhost", port, apiKey)
304
+
305
+ try {
306
+ await client.updateTrashQualityDefinitions(app.id as "radarr" | "sonarr")
307
+ debugLog("FullAutoSetup", `Configured quality settings for ${app.id}`)
308
+ } catch (e) {
309
+ debugLog("FullAutoSetup", `Failed to configure quality settings for ${app.id}: ${e}`)
310
+ }
311
+ }
312
+
313
+ this.updateStep("Quality Settings", "success")
314
+ } catch (e) {
315
+ this.updateStep("Quality Settings", "error", `${e}`)
316
+ }
317
+ this.refreshContent()
318
+ }
319
+
238
320
  private async setupAuthentication(): Promise<void> {
239
321
  this.updateStep("Authentication", "running")
240
322
  this.refreshContent()
@@ -317,6 +399,10 @@ export class FullAutoSetup extends BoxRenderable {
317
399
  debugLog("FullAutoSetup", "Failed to configure Bazarr -> Sonarr connection")
318
400
  }
319
401
  }
402
+
403
+ // TRaSH Recommended Settings
404
+ await bazarrClient.configureGeneralSettings()
405
+ await bazarrClient.configureDefaultLanguageProfile()
320
406
  }
321
407
  }
322
408
 
@@ -327,6 +413,56 @@ export class FullAutoSetup extends BoxRenderable {
327
413
  this.refreshContent()
328
414
  }
329
415
 
416
+ private async setupExternalUrls(): Promise<void> {
417
+ this.updateStep("External URLs", "running")
418
+ this.refreshContent()
419
+
420
+ let configured = 0
421
+
422
+ try {
423
+ // Configure *arr apps (Radarr, Sonarr, Lidarr, Readarr, Whisparr, Prowlarr)
424
+ const arrApps = this.config.apps.filter((a) => {
425
+ const def = getApp(a.id)
426
+ return a.enabled && (def?.rootFolder || a.id === "prowlarr")
427
+ })
428
+
429
+ for (const app of arrApps) {
430
+ const def = getApp(app.id)
431
+ if (!def) continue
432
+
433
+ const apiKey = this.env[`API_KEY_${app.id.toUpperCase()}`]
434
+ if (!apiKey) {
435
+ continue
436
+ }
437
+
438
+ const port = app.port || def.defaultPort
439
+ const apiVersion = app.id === "prowlarr" ? "v1" : def.rootFolder?.apiVersion || "v3"
440
+ const client = new ArrApiClient("localhost", port, apiKey, apiVersion)
441
+
442
+ try {
443
+ const applicationUrl = getApplicationUrl(app.id, port, this.config)
444
+ await client.setApplicationUrl(applicationUrl)
445
+ debugLog("FullAutoSetup", `Set applicationUrl for ${app.id}: ${applicationUrl}`)
446
+ configured++
447
+ } catch (e) {
448
+ debugLog("FullAutoSetup", `Failed to set applicationUrl for ${app.id}: ${e}`)
449
+ }
450
+ }
451
+
452
+ // Note: Jellyseerr and Overseerr are handled in their own setup steps
453
+ // (setupJellyseerr/setupOverseerr) because they require authentication first
454
+
455
+ if (configured > 0) {
456
+ this.updateStep("External URLs", "success", `${configured} apps configured`)
457
+ } else {
458
+ this.updateStep("External URLs", "skipped", "No apps with API keys")
459
+ }
460
+ } catch (e) {
461
+ this.updateStep("External URLs", "error", `${e}`)
462
+ }
463
+ this.refreshContent()
464
+ }
465
+
330
466
  private async setupProwlarrApps(): Promise<void> {
331
467
  this.updateStep("Prowlarr Apps", "running")
332
468
  this.refreshContent()
@@ -581,12 +717,16 @@ export class FullAutoSetup extends BoxRenderable {
581
717
  if (radarrConfig && this.env["API_KEY_RADARR"]) {
582
718
  try {
583
719
  const radarrDef = getApp("radarr")
720
+ const radarrPort = radarrConfig.port || radarrDef?.defaultPort || 7878
721
+ const radarrExternalUrl = getApplicationUrl("radarr", radarrPort, this.config)
584
722
  await client.configureRadarr(
585
723
  "radarr",
586
- radarrConfig.port || radarrDef?.defaultPort || 7878,
724
+ radarrPort,
587
725
  this.env["API_KEY_RADARR"],
588
- radarrDef?.rootFolder?.path || "/data/media/movies"
726
+ radarrDef?.rootFolder?.path || "/data/media/movies",
727
+ radarrExternalUrl
589
728
  )
729
+ debugLog("FullAutoSetup", `Jellyseerr: Radarr externalUrl set to ${radarrExternalUrl}`)
590
730
  } catch {
591
731
  /* Radarr config failed */
592
732
  }
@@ -596,17 +736,43 @@ export class FullAutoSetup extends BoxRenderable {
596
736
  if (sonarrConfig && this.env["API_KEY_SONARR"]) {
597
737
  try {
598
738
  const sonarrDef = getApp("sonarr")
739
+ const sonarrPort = sonarrConfig.port || sonarrDef?.defaultPort || 8989
740
+ const sonarrExternalUrl = getApplicationUrl("sonarr", sonarrPort, this.config)
599
741
  await client.configureSonarr(
600
742
  "sonarr",
601
- sonarrConfig.port || sonarrDef?.defaultPort || 8989,
743
+ sonarrPort,
602
744
  this.env["API_KEY_SONARR"],
603
- sonarrDef?.rootFolder?.path || "/data/media/tv"
745
+ sonarrDef?.rootFolder?.path || "/data/media/tv",
746
+ sonarrExternalUrl
604
747
  )
748
+ debugLog("FullAutoSetup", `Jellyseerr: Sonarr externalUrl set to ${sonarrExternalUrl}`)
605
749
  } catch {
606
750
  /* Sonarr config failed */
607
751
  }
608
752
  }
609
753
 
754
+ // Set Jellyfin's externalHostname for navigation links
755
+ const jellyfinConfig = this.config.apps.find((a) => a.id === "jellyfin" && a.enabled)
756
+ if (jellyfinConfig) {
757
+ try {
758
+ const jellyfinPort = jellyfinConfig.port || 8096
759
+ const jellyfinUrl = getApplicationUrl("jellyfin", jellyfinPort, this.config)
760
+ await client.updateJellyfinSettings({ externalHostname: jellyfinUrl })
761
+ debugLog("FullAutoSetup", `Jellyseerr: Jellyfin externalHostname set to ${jellyfinUrl}`)
762
+ } catch {
763
+ debugLog("FullAutoSetup", "Failed to set Jellyfin externalHostname in Jellyseerr")
764
+ }
765
+ }
766
+
767
+ // Set Jellyseerr's own applicationUrl (we're already authenticated from setup)
768
+ try {
769
+ const jellyseerrUrl = getApplicationUrl("jellyseerr", port, this.config)
770
+ await client.setApplicationUrl(jellyseerrUrl)
771
+ debugLog("FullAutoSetup", `Jellyseerr: applicationUrl set to ${jellyseerrUrl}`)
772
+ } catch {
773
+ debugLog("FullAutoSetup", "Failed to set Jellyseerr applicationUrl")
774
+ }
775
+
610
776
  this.updateStep("Jellyseerr", "success", result.message)
611
777
  } else {
612
778
  this.updateStep("Jellyseerr", "skipped", result.message)
@@ -874,6 +1040,16 @@ export class FullAutoSetup extends BoxRenderable {
874
1040
  await updateEnv(result.envUpdates)
875
1041
  Object.assign(this.env, result.envUpdates)
876
1042
  }
1043
+
1044
+ // Set Overseerr's applicationUrl (we're already authenticated from setup)
1045
+ try {
1046
+ const overseerrUrl = getApplicationUrl("overseerr", port, this.config)
1047
+ await client.setApplicationUrl(overseerrUrl)
1048
+ debugLog("FullAutoSetup", `Overseerr: applicationUrl set to ${overseerrUrl}`)
1049
+ } catch {
1050
+ debugLog("FullAutoSetup", "Failed to set Overseerr applicationUrl")
1051
+ }
1052
+
877
1053
  this.updateStep("Overseerr", "success", result.message)
878
1054
  } else {
879
1055
  this.updateStep("Overseerr", "skipped", result.message)
@@ -7,6 +7,7 @@ import { BoxRenderable, CliRenderer, TextRenderable, KeyEvent } from "@opentui/c
7
7
  import { createPageLayout } from "../components/PageLayout"
8
8
  import { EasiarrConfig, AppId } from "../../config/schema"
9
9
  import { getApp } from "../../apps/registry"
10
+ import { ArrApiClient } from "../../api/arr-api"
10
11
  import { QualityProfileClient } from "../../api/quality-profile-api"
11
12
  import { CustomFormatClient, getCFNamesForCategories } from "../../api/custom-format-api"
12
13
  import { getPresetsForApp, TRaSHProfilePreset } from "../../data/trash-profiles"
@@ -18,6 +19,7 @@ interface SetupResult {
18
19
  appName: string
19
20
  profile: string
20
21
  cfCount: number
22
+ namingConfigured: boolean
21
23
  status: "pending" | "configuring" | "success" | "error"
22
24
  message?: string
23
25
  }
@@ -185,6 +187,7 @@ export class TRaSHProfileSetup extends BoxRenderable {
185
187
  appName: appDef.name,
186
188
  profile: preset.name,
187
189
  cfCount: 0,
190
+ namingConfigured: false,
188
191
  status: "configuring",
189
192
  })
190
193
  this.refreshContent()
@@ -195,6 +198,7 @@ export class TRaSHProfileSetup extends BoxRenderable {
195
198
  if (result) {
196
199
  result.status = "success"
197
200
  result.cfCount = Object.keys(preset.cfScores).length
201
+ result.namingConfigured = true
198
202
  }
199
203
  } catch (error) {
200
204
  const result = this.results.find((r) => r.appId === appId)
@@ -237,6 +241,10 @@ export class TRaSHProfileSetup extends BoxRenderable {
237
241
 
238
242
  // Create quality profile
239
243
  await qpClient.createTRaSHProfile(preset.name, preset.cutoffQuality, preset.allowedQualities, preset.cfScores)
244
+
245
+ // Configure naming scheme
246
+ const arrClient = new ArrApiClient("localhost", port, apiKey, appDef.rootFolder?.apiVersion || "v3")
247
+ await arrClient.configureTRaSHNaming(appId as "radarr" | "sonarr")
240
248
  }
241
249
 
242
250
  private refreshContent(): void {
@@ -349,7 +357,7 @@ export class TRaSHProfileSetup extends BoxRenderable {
349
357
 
350
358
  let content = `${status} ${result.appName}: ${result.profile}`
351
359
  if (result.status === "success") {
352
- content += ` (${result.cfCount} CF scores)`
360
+ content += ` (${result.cfCount} CF scores, naming configured)`
353
361
  }
354
362
  if (result.message) {
355
363
  content += ` - ${result.message}`
@@ -0,0 +1,38 @@
1
+ /**
2
+ * URL Utilities
3
+ * Functions for generating app URLs based on Traefik configuration
4
+ */
5
+
6
+ import type { EasiarrConfig } from "../config/schema"
7
+ import { getLocalIp } from "./env"
8
+
9
+ /**
10
+ * Get the local URL for an app (http://LOCAL_IP:PORT)
11
+ */
12
+ export function getLocalAppUrl(port: number): string {
13
+ const localIp = getLocalIp()
14
+ return `http://${localIp}:${port}`
15
+ }
16
+
17
+ /**
18
+ * Get the external URL for an app (https://APP.DOMAIN)
19
+ * Returns null if Traefik is not enabled
20
+ */
21
+ export function getExternalAppUrl(appId: string, config: EasiarrConfig): string | null {
22
+ if (!config.traefik?.enabled || !config.traefik.domain) {
23
+ return null
24
+ }
25
+ return `https://${appId}.${config.traefik.domain}`
26
+ }
27
+
28
+ /**
29
+ * Get the appropriate applicationUrl based on Traefik status
30
+ * Returns external URL if Traefik enabled, otherwise local URL
31
+ */
32
+ export function getApplicationUrl(appId: string, port: number, config: EasiarrConfig): string {
33
+ const externalUrl = getExternalAppUrl(appId, config)
34
+ if (externalUrl) {
35
+ return externalUrl
36
+ }
37
+ return getLocalAppUrl(port)
38
+ }