@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 +1 -1
- package/src/api/arr-api.ts +53 -0
- package/src/api/bazarr-api.ts +83 -0
- package/src/api/jellyseerr-api.ts +82 -8
- package/src/api/naming-config.ts +67 -0
- package/src/api/overseerr-api.ts +24 -0
- package/src/api/qbittorrent-api.ts +58 -0
- package/src/api/quality-profile-api.ts +54 -4
- package/src/apps/registry.ts +15 -1
- package/src/config/homepage-config.ts +25 -8
- package/src/config/trash-quality-definitions.ts +187 -0
- package/src/ui/screens/FullAutoSetup.ts +197 -21
- package/src/ui/screens/TRaSHProfileSetup.ts +9 -1
- package/src/utils/url-utils.ts +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muhammedaksam/easiarr",
|
|
3
|
-
"version": "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",
|
package/src/api/arr-api.ts
CHANGED
|
@@ -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",
|
package/src/api/bazarr-api.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|
package/src/api/overseerr-api.ts
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
}
|
package/src/apps/registry.ts
CHANGED
|
@@ -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([
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
157
|
+
// Step 3: External URLs
|
|
158
|
+
await this.setupExternalUrls()
|
|
159
|
+
|
|
160
|
+
// Step 4: Prowlarr apps
|
|
147
161
|
await this.setupProwlarrApps()
|
|
148
162
|
|
|
149
|
-
// Step
|
|
163
|
+
// Step 5: FlareSolverr
|
|
150
164
|
await this.setupFlareSolverr()
|
|
151
165
|
|
|
152
|
-
// Step
|
|
166
|
+
// Step 6: qBittorrent
|
|
153
167
|
await this.setupQBittorrent()
|
|
154
168
|
|
|
155
|
-
// Step
|
|
169
|
+
// Step 7: Portainer
|
|
156
170
|
await this.setupPortainer()
|
|
157
171
|
|
|
158
|
-
// Step
|
|
172
|
+
// Step 8: Jellyfin
|
|
159
173
|
await this.setupJellyfin()
|
|
160
174
|
|
|
161
|
-
// Step
|
|
175
|
+
// Step 9: Jellyseerr
|
|
162
176
|
await this.setupJellyseerr()
|
|
163
177
|
|
|
164
|
-
// Step
|
|
178
|
+
// Step 10: Plex
|
|
165
179
|
await this.setupPlex()
|
|
166
180
|
|
|
167
|
-
// Step
|
|
181
|
+
// Step 11: Overseerr (requires Plex)
|
|
168
182
|
await this.setupOverseerr()
|
|
169
183
|
|
|
170
|
-
// Step
|
|
184
|
+
// Step 12: Tautulli (Plex monitoring)
|
|
171
185
|
await this.setupTautulli()
|
|
172
186
|
|
|
173
|
-
// Step
|
|
187
|
+
// Step 13: Bazarr (subtitles)
|
|
174
188
|
await this.setupBazarr()
|
|
175
189
|
|
|
176
|
-
// Step
|
|
190
|
+
// Step 14: Uptime Kuma (monitors)
|
|
177
191
|
await this.setupUptimeKuma()
|
|
178
192
|
|
|
179
|
-
// Step
|
|
193
|
+
// Step 15: Grafana (dashboards)
|
|
180
194
|
await this.setupGrafana()
|
|
181
195
|
|
|
182
|
-
// Step
|
|
196
|
+
// Step 16: Homarr (dashboard)
|
|
183
197
|
await this.setupHomarr()
|
|
184
198
|
|
|
185
|
-
// Step
|
|
199
|
+
// Step 17: Heimdall (dashboard)
|
|
186
200
|
await this.setupHeimdall()
|
|
187
201
|
|
|
188
|
-
// Step
|
|
202
|
+
// Step 18: Huntarr (*arr app manager)
|
|
189
203
|
await this.setupHuntarr()
|
|
190
204
|
|
|
191
|
-
// Step
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|