@muhammedaksam/easiarr 1.1.2 → 1.1.4

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.2",
3
+ "version": "1.1.4",
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",
@@ -278,7 +278,7 @@ export class ArrApiClient {
278
278
  })
279
279
  }
280
280
 
281
- async configureTRaSHNaming(appType: "radarr" | "sonarr"): Promise<void> {
281
+ async configureTRaSHNaming(appType: "radarr" | "sonarr" | "lidarr"): Promise<void> {
282
282
  try {
283
283
  // 1. Get current configuration to preserve ID and other fields
284
284
  const currentConfig = await this.getNamingConfig<NamingConfig & { id?: number }>()
@@ -26,11 +26,22 @@ export interface SonarrNamingConfig {
26
26
  numberStyle: string
27
27
  }
28
28
 
29
- export type NamingConfig = RadarrNamingConfig | SonarrNamingConfig
29
+ export interface LidarrNamingConfig {
30
+ renameTracks: boolean
31
+ replaceIllegalCharacters: boolean
32
+ colonReplacementFormat: "dash" | "spaceDash" | "spaceDashSpace" | "smart" | "delete" | number
33
+ standardTrackFormat: string
34
+ multiDiscTrackFormat: string
35
+ artistFolderFormat: string
36
+ albumFolderFormat: string
37
+ }
38
+
39
+ export type NamingConfig = RadarrNamingConfig | SonarrNamingConfig | LidarrNamingConfig
30
40
 
31
41
  // TRaSH Guides Recommended Naming Schemes
32
42
  // Source: https://trash-guides.info/Radarr/Radarr-recommended-naming-scheme/
33
43
  // Source: https://trash-guides.info/Sonarr/Sonarr-recommended-naming-scheme/
44
+ // Lidarr: https://wiki.servarr.com/lidarr/settings#media-management
34
45
 
35
46
  export const TRASH_NAMING_CONFIG = {
36
47
  radarr: {
@@ -64,4 +75,19 @@ export const TRASH_NAMING_CONFIG = {
64
75
  separator: " - ",
65
76
  numberStyle: "S{season:00}E{episode:00}",
66
77
  } as SonarrNamingConfig,
78
+
79
+ lidarr: {
80
+ renameTracks: true,
81
+ replaceIllegalCharacters: true,
82
+ colonReplacementFormat: 4, // 4 = Smart Replace (Dash or Space Dash depending on name)
83
+ // Standard track format: Artist - Album (Year) - Track# - Title
84
+ standardTrackFormat: "{Artist CleanName} - {Album CleanTitle} ({Release Year}) - {track:00} - {Track CleanTitle}",
85
+ // Multi-disc format includes disc number
86
+ multiDiscTrackFormat:
87
+ "{Artist CleanName} - {Album CleanTitle} ({Release Year}) - {medium:00}-{track:00} - {Track CleanTitle}",
88
+ // Artist folder: Artist Name
89
+ artistFolderFormat: "{Artist CleanName}",
90
+ // Album folder: Album Title (Year) [Quality]
91
+ albumFolderFormat: "{Album CleanTitle} ({Release Year})",
92
+ } as LidarrNamingConfig,
67
93
  }
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Quality Profile API Client
3
- * Manages Quality Profiles and Custom Format scoring for Radarr/Sonarr
3
+ * Manages Quality Profiles and Custom Format scoring for Radarr/Sonarr/Lidarr
4
4
  */
5
5
 
6
6
  import { debugLog } from "../utils/debug"
7
- import { TRASH_RADARR_QUALITY_DEFINITIONS, TRASH_SONARR_QUALITY_DEFINITIONS } from "../config/trash-quality-definitions"
7
+ import {
8
+ TRASH_RADARR_QUALITY_DEFINITIONS,
9
+ TRASH_SONARR_QUALITY_DEFINITIONS,
10
+ TRASH_LIDARR_QUALITY_DEFINITIONS,
11
+ } from "../config/trash-quality-definitions"
8
12
 
9
13
  export interface QualityItem {
10
14
  id?: number
@@ -219,12 +223,22 @@ export class QualityProfileClient {
219
223
  return this.updateQualityProfile(profileId, profile)
220
224
  }
221
225
 
222
- // Apply TRaSH Quality Definitions (File Size Limits)
223
- async updateTrashQualityDefinitions(appType: "radarr" | "sonarr"): Promise<void> {
226
+ // Apply TRaSH/Davo Quality Definitions (File Size Limits)
227
+ async updateTrashQualityDefinitions(appType: "radarr" | "sonarr" | "lidarr"): Promise<void> {
224
228
  try {
225
229
  debugLog("QualityProfile", `Updating Quality Definitions for ${appType}`)
226
230
  const definitions = await this.getQualityDefinitions()
227
- const trashDefs = appType === "radarr" ? TRASH_RADARR_QUALITY_DEFINITIONS : TRASH_SONARR_QUALITY_DEFINITIONS
231
+
232
+ // Get appropriate definitions based on app type
233
+ let trashDefs
234
+ if (appType === "radarr") {
235
+ trashDefs = TRASH_RADARR_QUALITY_DEFINITIONS
236
+ } else if (appType === "sonarr") {
237
+ trashDefs = TRASH_SONARR_QUALITY_DEFINITIONS
238
+ } else {
239
+ trashDefs = TRASH_LIDARR_QUALITY_DEFINITIONS
240
+ }
241
+
228
242
  let updatedCount = 0
229
243
 
230
244
  const newDefinitions = definitions.map((def) => {
@@ -313,6 +313,62 @@ export const APPS: Record<AppId, AppDefinition> = {
313
313
  homepage: { icon: "sabnzbd.png", widget: "sabnzbd" },
314
314
  },
315
315
 
316
+ slskd: {
317
+ id: "slskd",
318
+ name: "Slskd",
319
+ description: "Soulseek client for music downloads",
320
+ category: "downloader",
321
+ defaultPort: 5030,
322
+ image: "slskd/slskd",
323
+ puid: 13016,
324
+ pgid: 13000,
325
+ volumes: (root) => [`${root}/config/slskd:/app`, `${root}/data:/data`],
326
+ secondaryPorts: ["5031:5031", "50300:50300"],
327
+ environment: {
328
+ SLSKD_REMOTE_CONFIGURATION: "true",
329
+ // Web UI authentication (use global credentials)
330
+ SLSKD_USERNAME: "${USERNAME_GLOBAL}",
331
+ SLSKD_PASSWORD: "${PASSWORD_GLOBAL}",
332
+ // Soulseek network credentials (separate account)
333
+ SLSKD_SLSK_USERNAME: "${USERNAME_SOULSEEK}",
334
+ SLSKD_SLSK_PASSWORD: "${PASSWORD_SOULSEEK}",
335
+ },
336
+ secrets: [
337
+ {
338
+ name: "USERNAME_SOULSEEK",
339
+ description: "Soulseek network username (your Soulseek account)",
340
+ required: true,
341
+ },
342
+ {
343
+ name: "PASSWORD_SOULSEEK",
344
+ description: "Soulseek network password",
345
+ required: true,
346
+ mask: true,
347
+ },
348
+ ],
349
+ homepage: { icon: "slskd.png", widget: "slskd" },
350
+ autoSetup: {
351
+ type: "partial",
352
+ description: "Generates slskd.yml with API key for Homepage widget and Soularr integration",
353
+ },
354
+ },
355
+
356
+ soularr: {
357
+ id: "soularr",
358
+ name: "Soularr",
359
+ description: "Connects Lidarr with Soulseek via Slskd",
360
+ category: "downloader",
361
+ defaultPort: 0, // No web UI
362
+ image: "mrusse08/soularr:latest",
363
+ puid: 13017,
364
+ pgid: 13000,
365
+ volumes: (root) => [`${root}/data/slskd_downloads:/downloads`, `${root}/config/soularr:/data`],
366
+ environment: {
367
+ SCRIPT_INTERVAL: "300",
368
+ },
369
+ dependsOn: ["lidarr", "slskd"],
370
+ },
371
+
316
372
  // === MEDIA SERVERS ===
317
373
  plex: {
318
374
  id: "plex",
@@ -55,6 +55,9 @@ export async function generateServicesYaml(config: EasiarrConfig): Promise<strin
55
55
  // Skip homepage itself
56
56
  if (appDef.id === "homepage") continue
57
57
 
58
+ // Skip apps with no web UI (port 0) and no homepage config
59
+ if (appDef.defaultPort === 0 && !appDef.homepage) continue
60
+
58
61
  const port = appConfig.port ?? appDef.defaultPort
59
62
  const internalPort = appDef.internalPort ?? port
60
63
  // External URL for user browser access (href, ping)
@@ -106,6 +106,8 @@ export type AppId =
106
106
  // Download Clients
107
107
  | "qbittorrent"
108
108
  | "sabnzbd"
109
+ | "slskd"
110
+ | "soularr"
109
111
  // Media Servers
110
112
  | "plex"
111
113
  | "jellyfin"
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Slskd Config Generator
3
+ * Generates slskd.yml configuration file with API keys and settings
4
+ * Source: https://github.com/slskd/slskd/blob/master/docs/config.md
5
+ */
6
+
7
+ import type { EasiarrConfig } from "./schema"
8
+ import { readEnvSync } from "../utils/env"
9
+ import { randomBytes } from "crypto"
10
+
11
+ /**
12
+ * Generate a random API key (base64, 48 bytes = ~64 chars)
13
+ * Equivalent to: openssl rand -base64 48
14
+ */
15
+ export function generateSlskdApiKey(): string {
16
+ return randomBytes(48).toString("base64")
17
+ }
18
+
19
+ /**
20
+ * Generate slskd.yml content
21
+ * This file should be placed in the slskd config directory (/app/slskd.yml)
22
+ */
23
+ export function generateSlskdConfig(_config: EasiarrConfig): { yaml: string; apiKey: string } {
24
+ const env = readEnvSync()
25
+
26
+ // Generate or use existing API key
27
+ const apiKey = env.API_KEY_SLSKD || generateSlskdApiKey()
28
+
29
+ const yaml = `# Slskd Configuration
30
+ # Generated by Easiarr
31
+ # Docs: https://github.com/slskd/slskd/blob/master/docs/config.md
32
+
33
+ # Directories for downloads
34
+ directories:
35
+ incomplete: /data/slskd_downloads/incomplete
36
+ downloads: /data/slskd_downloads/complete
37
+
38
+ # Shares - music directory for uploading
39
+ shares:
40
+ directories:
41
+ - /data/media/music
42
+
43
+ # Web UI Configuration
44
+ web:
45
+ port: 5030
46
+ authentication:
47
+ disabled: false
48
+ api_keys:
49
+ # API key for Homepage widget and Soularr
50
+ easiarr:
51
+ key: ${apiKey}
52
+ role: readwrite
53
+ cidr: 0.0.0.0/0,::/0
54
+
55
+ # Soulseek Configuration
56
+ # Credentials are set via environment variables:
57
+ # - SLSKD_SLSK_USERNAME
58
+ # - SLSKD_SLSK_PASSWORD
59
+ soulseek:
60
+ listen_port: 50300
61
+ description: "slskd user via Easiarr"
62
+
63
+ # Global limits
64
+ global:
65
+ upload:
66
+ slots: 10
67
+ speed_limit: 1000
68
+ download:
69
+ slots: 100
70
+ speed_limit: 10000
71
+
72
+ # Logging
73
+ logger:
74
+ disk: false
75
+ `
76
+
77
+ return { yaml, apiKey }
78
+ }
79
+
80
+ /**
81
+ * Get the path where slskd.yml should be saved
82
+ */
83
+ export function getSlskdConfigPath(rootDir: string): string {
84
+ return `${rootDir}/config/slskd/slskd.yml`
85
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Soularr Config Generator
3
+ * Generates config.ini for Soularr based on easiarr configuration
4
+ * Source: https://github.com/mrusse/soularr
5
+ */
6
+
7
+ import type { EasiarrConfig } from "./schema"
8
+ import { readEnvSync } from "../utils/env"
9
+
10
+ /**
11
+ * Generate Soularr config.ini content
12
+ * This file should be placed in the Soularr config directory
13
+ */
14
+ export function generateSoularrConfig(config: EasiarrConfig): string {
15
+ const env = readEnvSync()
16
+
17
+ const lidarrApiKey = env.API_KEY_LIDARR || "yourlidarrapikeygoeshere"
18
+ const slskdApiKey = env.API_KEY_SLSKD || "yourslskdapikeygoeshere"
19
+
20
+ // Find Lidarr and Slskd ports from config
21
+ const lidarrPort = config.apps.find((a) => a.id === "lidarr")?.port || 8686
22
+ const slskdPort = config.apps.find((a) => a.id === "slskd")?.port || 5030
23
+
24
+ return `[Lidarr]
25
+ # Get from Lidarr: Settings > General > Security
26
+ api_key = ${lidarrApiKey}
27
+ # URL Lidarr uses (internal Docker network)
28
+ host_url = http://lidarr:${lidarrPort}
29
+ # Path to slskd downloads inside the Lidarr container
30
+ download_dir = /data/slskd_downloads
31
+ # If true, Lidarr won't auto-import from Slskd (recommended: True to avoid failed_imports issues)
32
+ disable_sync = True
33
+
34
+ [Slskd]
35
+ # Create manually in Slskd web UI
36
+ api_key = ${slskdApiKey}
37
+ # URL Slskd uses (internal Docker network)
38
+ host_url = http://slskd:${slskdPort}
39
+ url_base = /
40
+ # Download path inside Slskd container
41
+ download_dir = /downloads
42
+ # Delete search after Soularr runs
43
+ delete_searches = False
44
+ # Max seconds to wait for downloads (prevents infinite hangs)
45
+ stalled_timeout = 3600
46
+
47
+ [Release Settings]
48
+ # Pick release with most common track count
49
+ use_most_common_tracknum = True
50
+ allow_multi_disc = True
51
+ # Accepted release countries
52
+ accepted_countries = Europe,Japan,United Kingdom,United States,[Worldwide],Australia,Canada
53
+ # Don't check the region of the release
54
+ skip_region_check = False
55
+ # Accepted formats
56
+ accepted_formats = CD,Digital Media,Vinyl
57
+
58
+ [Search Settings]
59
+ search_timeout = 5000
60
+ maximum_peer_queue = 50
61
+ # Minimum upload speed (bits/sec)
62
+ minimum_peer_upload_speed = 0
63
+ # Minimum match ratio between Lidarr track and Soulseek filename
64
+ minimum_filename_match_ratio = 0.8
65
+ # Preferred file types and qualities (most to least preferred)
66
+ allowed_filetypes = flac 24/192,flac 16/44.1,flac,mp3 320,mp3
67
+ ignored_users =
68
+ # Set to False to only search for album titles
69
+ search_for_tracks = True
70
+ # Prepend artist name when searching
71
+ album_prepend_artist = False
72
+ track_prepend_artist = True
73
+ # Search modes: all, incrementing_page, first_page
74
+ search_type = incrementing_page
75
+ # Albums to process per run
76
+ number_of_albums_to_grab = 10
77
+ # Unmonitor album on failure
78
+ remove_wanted_on_failure = False
79
+ # Blacklist words in album or track titles
80
+ title_blacklist =
81
+ # Lidarr search source: "missing" or "cutoff_unmet"
82
+ search_source = missing
83
+ # Enable search denylist to skip albums that repeatedly fail
84
+ enable_search_denylist = False
85
+ # Number of consecutive search failures before denylisting
86
+ max_search_failures = 3
87
+
88
+ [Download Settings]
89
+ download_filtering = True
90
+ use_extension_whitelist = False
91
+ extensions_whitelist = lrc,nfo,txt
92
+
93
+ [Logging]
94
+ level = INFO
95
+ format = [%(levelname)s|%(module)s|L%(lineno)d] %(asctime)s: %(message)s
96
+ datefmt = %Y-%m-%dT%H:%M:%S%z
97
+ `
98
+ }
99
+
100
+ /**
101
+ * Get the path where Soularr config should be saved
102
+ */
103
+ export function getSoularrConfigPath(rootDir: string): string {
104
+ return `${rootDir}/config/soularr/config.ini`
105
+ }
@@ -185,3 +185,29 @@ export const TRASH_SONARR_QUALITY_DEFINITIONS: TrashQualityDefinition[] = [
185
185
  max: 1000,
186
186
  },
187
187
  ]
188
+
189
+ /**
190
+ * Lidarr Quality Definitions (Davo's Community Guide)
191
+ * Source: https://wiki.servarr.com/lidarr/community-guide
192
+ * Filters out single track rips for entire albums (FLAC/CUE files)
193
+ */
194
+ export const TRASH_LIDARR_QUALITY_DEFINITIONS: TrashQualityDefinition[] = [
195
+ {
196
+ quality: "FLAC",
197
+ min: 0,
198
+ preferred: 895,
199
+ max: 1400,
200
+ },
201
+ {
202
+ quality: "FLAC 24bit",
203
+ min: 0,
204
+ preferred: 895,
205
+ max: 1495,
206
+ },
207
+ {
208
+ quality: "MP3-320",
209
+ min: 0,
210
+ preferred: 0,
211
+ max: 0, // No size limit for MP3
212
+ },
213
+ ]
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Lidarr Custom Formats (Davo's Community Guide)
3
+ * Source: https://wiki.servarr.com/lidarr/community-guide
4
+ *
5
+ * Unlike Radarr/Sonarr, these are NOT from TRaSH Guides.
6
+ * Lidarr custom formats use different implementation types.
7
+ * Uses the same CustomFormat interface from custom-format-api.ts for compatibility.
8
+ */
9
+
10
+ import type { CustomFormat } from "../api/custom-format-api"
11
+
12
+ /**
13
+ * Preferred Groups - Release groups that are consistently high quality
14
+ */
15
+ export const CF_PREFERRED_GROUPS: Omit<CustomFormat, "id"> = {
16
+ name: "Preferred Groups",
17
+ includeCustomFormatWhenRenaming: false,
18
+ specifications: [
19
+ {
20
+ name: "DeVOiD",
21
+ implementation: "ReleaseGroupSpecification",
22
+ negate: false,
23
+ required: false,
24
+ fields: [{ name: "value", value: "\\bDeVOiD\\b" }],
25
+ },
26
+ {
27
+ name: "PERFECT",
28
+ implementation: "ReleaseGroupSpecification",
29
+ negate: false,
30
+ required: false,
31
+ fields: [{ name: "value", value: "\\bPERFECT\\b" }],
32
+ },
33
+ {
34
+ name: "ENRiCH",
35
+ implementation: "ReleaseGroupSpecification",
36
+ negate: false,
37
+ required: false,
38
+ fields: [{ name: "value", value: "\\bENRiCH\\b" }],
39
+ },
40
+ ],
41
+ }
42
+
43
+ /**
44
+ * CD - Tag releases that are from CD source
45
+ */
46
+ export const CF_CD: Omit<CustomFormat, "id"> = {
47
+ name: "CD",
48
+ includeCustomFormatWhenRenaming: false,
49
+ specifications: [
50
+ {
51
+ name: "CD",
52
+ implementation: "ReleaseTitleSpecification",
53
+ negate: false,
54
+ required: false,
55
+ fields: [{ name: "value", value: "\\bCD\\b" }],
56
+ },
57
+ ],
58
+ }
59
+
60
+ /**
61
+ * WEB - Tag releases that are from WEB source
62
+ */
63
+ export const CF_WEB: Omit<CustomFormat, "id"> = {
64
+ name: "WEB",
65
+ includeCustomFormatWhenRenaming: false,
66
+ specifications: [
67
+ {
68
+ name: "WEB",
69
+ implementation: "ReleaseTitleSpecification",
70
+ negate: false,
71
+ required: false,
72
+ fields: [{ name: "value", value: "\\bWEB\\b" }],
73
+ },
74
+ ],
75
+ }
76
+
77
+ /**
78
+ * Lossless - Tag releases that are lossless (flac/flac24)
79
+ */
80
+ export const CF_LOSSLESS: Omit<CustomFormat, "id"> = {
81
+ name: "Lossless",
82
+ includeCustomFormatWhenRenaming: false,
83
+ specifications: [
84
+ {
85
+ name: "Flac",
86
+ implementation: "ReleaseTitleSpecification",
87
+ negate: false,
88
+ required: false,
89
+ fields: [{ name: "value", value: "\\blossless\\b" }],
90
+ },
91
+ ],
92
+ }
93
+
94
+ /**
95
+ * Vinyl - Tag releases that are from Vinyl source
96
+ */
97
+ export const CF_VINYL: Omit<CustomFormat, "id"> = {
98
+ name: "Vinyl",
99
+ includeCustomFormatWhenRenaming: false,
100
+ specifications: [
101
+ {
102
+ name: "Vinyl",
103
+ implementation: "ReleaseTitleSpecification",
104
+ negate: false,
105
+ required: false,
106
+ fields: [{ name: "value", value: "\\bVinyl\\b" }],
107
+ },
108
+ ],
109
+ }
110
+
111
+ /**
112
+ * All Lidarr custom formats from Davo's guide
113
+ */
114
+ export const LIDARR_CUSTOM_FORMATS: Omit<CustomFormat, "id">[] = [
115
+ CF_PREFERRED_GROUPS,
116
+ CF_CD,
117
+ CF_WEB,
118
+ CF_LOSSLESS,
119
+ CF_VINYL,
120
+ ]
121
+
122
+ /**
123
+ * Get all Lidarr custom format names
124
+ */
125
+ export function getLidarrCFNames(): string[] {
126
+ return LIDARR_CUSTOM_FORMATS.map((cf) => cf.name)
127
+ }
@@ -7,7 +7,7 @@ export interface TRaSHProfilePreset {
7
7
  id: string
8
8
  name: string
9
9
  description: string
10
- app: "radarr" | "sonarr" | "both"
10
+ app: "radarr" | "sonarr" | "lidarr" | "both"
11
11
  cutoffQuality: string
12
12
  allowedQualities: string[]
13
13
  cfScores: Record<string, number>
@@ -239,14 +239,88 @@ export const SONARR_PRESETS: TRaSHProfilePreset[] = [
239
239
  },
240
240
  ]
241
241
 
242
+ // Lidarr Quality Names (Davo's Community Guide)
243
+ export const LIDARR_QUALITIES = {
244
+ FLAC: "FLAC",
245
+ "FLAC 24bit": "FLAC 24bit",
246
+ "MP3-320": "MP3-320",
247
+ "MP3-256": "MP3-256",
248
+ "MP3-128": "MP3-128",
249
+ }
250
+
251
+ // Lidarr Custom Format Scores (Davo's Community Guide)
252
+ // Scoring requires minimum 1 to grab, prefers CD > WEB, avoids Vinyl
253
+ export const LIDARR_CF_SCORES = {
254
+ // Preferred Release Groups
255
+ "Preferred Groups": 5,
256
+
257
+ // Source Preferences
258
+ CD: 3, // Prefer CD source
259
+ WEB: 2, // Accept WEB source
260
+ Lossless: 1, // Tag for lossless
261
+
262
+ // Avoid Vinyl (analog noise, pops, clicks)
263
+ Vinyl: -10000,
264
+ }
265
+
266
+ // Lidarr Profile Presets (Davo's Community Guide)
267
+ export const LIDARR_PRESETS: TRaSHProfilePreset[] = [
268
+ {
269
+ id: "high-quality-flac",
270
+ name: "High Quality FLAC",
271
+ description: "FLAC quality with MP3-320 fallback. Prefers CD source, avoids Vinyl.",
272
+ app: "lidarr",
273
+ cutoffQuality: "FLAC",
274
+ allowedQualities: ["FLAC", "FLAC 24bit", "MP3-320"],
275
+ cfScores: {
276
+ "Preferred Groups": 5,
277
+ CD: 3,
278
+ WEB: 2,
279
+ Lossless: 1,
280
+ Vinyl: -10000,
281
+ },
282
+ },
283
+ {
284
+ id: "flac-only",
285
+ name: "FLAC Only",
286
+ description: "Lossless FLAC only, no MP3 fallback. Best quality.",
287
+ app: "lidarr",
288
+ cutoffQuality: "FLAC",
289
+ allowedQualities: ["FLAC", "FLAC 24bit"],
290
+ cfScores: {
291
+ "Preferred Groups": 5,
292
+ CD: 3,
293
+ WEB: 2,
294
+ Lossless: 1,
295
+ Vinyl: -10000,
296
+ },
297
+ },
298
+ {
299
+ id: "any-quality",
300
+ name: "Any Quality",
301
+ description: "Accept any quality including MP3. For maximum availability.",
302
+ app: "lidarr",
303
+ cutoffQuality: "MP3-320",
304
+ allowedQualities: ["FLAC", "FLAC 24bit", "MP3-320", "MP3-256", "MP3-128"],
305
+ cfScores: {
306
+ "Preferred Groups": 5,
307
+ CD: 3,
308
+ WEB: 2,
309
+ Lossless: 1,
310
+ Vinyl: -10000,
311
+ },
312
+ },
313
+ ]
314
+
242
315
  // Get all presets for an app
243
- export function getPresetsForApp(app: "radarr" | "sonarr"): TRaSHProfilePreset[] {
316
+ export function getPresetsForApp(app: "radarr" | "sonarr" | "lidarr"): TRaSHProfilePreset[] {
244
317
  if (app === "radarr") return RADARR_PRESETS
245
318
  if (app === "sonarr") return SONARR_PRESETS
319
+ if (app === "lidarr") return LIDARR_PRESETS
246
320
  return []
247
321
  }
248
322
 
249
323
  // Get a specific preset by ID
250
324
  export function getPresetById(id: string): TRaSHProfilePreset | undefined {
251
- return [...RADARR_PRESETS, ...SONARR_PRESETS].find((p) => p.id === id)
325
+ return [...RADARR_PRESETS, ...SONARR_PRESETS, ...LIDARR_PRESETS].find((p) => p.id === id)
252
326
  }
@@ -25,6 +25,11 @@ import { HeimdallClient } from "../../api/heimdall-api"
25
25
  import { HuntarrClient } from "../../api/huntarr-api"
26
26
  import { saveConfig } from "../../config"
27
27
  import { saveCompose } from "../../compose"
28
+ import { generateSlskdConfig, getSlskdConfigPath } from "../../config/slskd-config"
29
+ import { generateSoularrConfig, getSoularrConfigPath } from "../../config/soularr-config"
30
+ import { writeFile, mkdir } from "fs/promises"
31
+ import { dirname } from "path"
32
+ import { existsSync } from "fs"
28
33
  import { getApp } from "../../apps/registry"
29
34
  // import type { AppId } from "../../config/schema"
30
35
  import { getCategoriesForApps } from "../../utils/categories"
@@ -111,6 +116,8 @@ export class FullAutoSetup extends BoxRenderable {
111
116
  { name: "Homarr", status: "pending" },
112
117
  { name: "Heimdall", status: "pending" },
113
118
  { name: "Huntarr", status: "pending" },
119
+ { name: "Slskd", status: "pending" },
120
+ { name: "Soularr", status: "pending" },
114
121
  { name: "Cloudflare Tunnel", status: "pending" },
115
122
  ]
116
123
  }
@@ -202,7 +209,13 @@ export class FullAutoSetup extends BoxRenderable {
202
209
  // Step 18: Huntarr (*arr app manager)
203
210
  await this.setupHuntarr()
204
211
 
205
- // Step 19: Cloudflare Tunnel
212
+ // Step 19: Slskd (Soulseek client)
213
+ await this.setupSlskd()
214
+
215
+ // Step 20: Soularr (Lidarr -> Slskd bridge)
216
+ await this.setupSoularr()
217
+
218
+ // Step 21: Cloudflare Tunnel
206
219
  await this.setupCloudflare()
207
220
 
208
221
  this.isRunning = false
@@ -255,7 +268,7 @@ export class FullAutoSetup extends BoxRenderable {
255
268
 
256
269
  try {
257
270
  const arrApps = this.config.apps.filter((a) => {
258
- return a.enabled && (a.id === "radarr" || a.id === "sonarr")
271
+ return a.enabled && (a.id === "radarr" || a.id === "sonarr" || a.id === "lidarr")
259
272
  })
260
273
 
261
274
  for (const app of arrApps) {
@@ -269,7 +282,7 @@ export class FullAutoSetup extends BoxRenderable {
269
282
  const client = new ArrApiClient("localhost", port, apiKey, def.rootFolder?.apiVersion || "v3")
270
283
 
271
284
  try {
272
- await client.configureTRaSHNaming(app.id as "radarr" | "sonarr")
285
+ await client.configureTRaSHNaming(app.id as "radarr" | "sonarr" | "lidarr")
273
286
  debugLog("FullAutoSetup", `Configured naming for ${app.id}`)
274
287
  } catch (e) {
275
288
  debugLog("FullAutoSetup", `Failed to configure naming for ${app.id}: ${e}`)
@@ -289,7 +302,7 @@ export class FullAutoSetup extends BoxRenderable {
289
302
 
290
303
  try {
291
304
  const arrApps = this.config.apps.filter((a) => {
292
- return a.enabled && (a.id === "radarr" || a.id === "sonarr")
305
+ return a.enabled && (a.id === "radarr" || a.id === "sonarr" || a.id === "lidarr")
293
306
  })
294
307
 
295
308
  for (const app of arrApps) {
@@ -300,10 +313,11 @@ export class FullAutoSetup extends BoxRenderable {
300
313
  if (!def) continue
301
314
 
302
315
  const port = app.port || def.defaultPort
303
- const client = new QualityProfileClient("localhost", port, apiKey)
316
+ const apiVersion = def.rootFolder?.apiVersion || "v3"
317
+ const client = new QualityProfileClient("localhost", port, apiKey, apiVersion)
304
318
 
305
319
  try {
306
- await client.updateTrashQualityDefinitions(app.id as "radarr" | "sonarr")
320
+ await client.updateTrashQualityDefinitions(app.id as "radarr" | "sonarr" | "lidarr")
307
321
  debugLog("FullAutoSetup", `Configured quality settings for ${app.id}`)
308
322
  } catch (e) {
309
323
  debugLog("FullAutoSetup", `Failed to configure quality settings for ${app.id}: ${e}`)
@@ -1284,6 +1298,92 @@ export class FullAutoSetup extends BoxRenderable {
1284
1298
  this.refreshContent()
1285
1299
  }
1286
1300
 
1301
+ private async setupSlskd(): Promise<void> {
1302
+ this.updateStep("Slskd", "running")
1303
+ this.refreshContent()
1304
+
1305
+ const slskdConfig = this.config.apps.find((a) => a.id === "slskd" && a.enabled)
1306
+ if (!slskdConfig) {
1307
+ this.updateStep("Slskd", "skipped", "Not enabled")
1308
+ this.refreshContent()
1309
+ return
1310
+ }
1311
+
1312
+ try {
1313
+ // Generate slskd.yml config with auto-generated API key
1314
+ const { yaml, apiKey } = generateSlskdConfig(this.config)
1315
+ const configPath = getSlskdConfigPath(this.config.rootDir)
1316
+
1317
+ // Ensure config directory exists
1318
+ const configDir = dirname(configPath)
1319
+ if (!existsSync(configDir)) {
1320
+ await mkdir(configDir, { recursive: true })
1321
+ }
1322
+
1323
+ // Create slskd download directories (like qBittorrent categories)
1324
+ const slskdDownloadsDir = `${this.config.rootDir}/data/slskd_downloads`
1325
+ await mkdir(`${slskdDownloadsDir}/incomplete`, { recursive: true })
1326
+ await mkdir(`${slskdDownloadsDir}/complete`, { recursive: true })
1327
+ debugLog("FullAutoSetup", `Created slskd download directories at ${slskdDownloadsDir}`)
1328
+
1329
+ // Always write slskd.yml - Docker creates a commented-out example that we need to replace
1330
+ await writeFile(configPath, yaml)
1331
+ debugLog("FullAutoSetup", `Generated slskd.yml at ${configPath}`)
1332
+
1333
+ // Save API key to .env for Homepage widget and Soularr
1334
+ await updateEnv({ API_KEY_SLSKD: apiKey })
1335
+ this.env["API_KEY_SLSKD"] = apiKey
1336
+
1337
+ this.updateStep("Slskd", "success", "Config generated, API key saved")
1338
+ } catch (e) {
1339
+ this.updateStep("Slskd", "error", `${e}`)
1340
+ }
1341
+ this.refreshContent()
1342
+ }
1343
+
1344
+ private async setupSoularr(): Promise<void> {
1345
+ this.updateStep("Soularr", "running")
1346
+ this.refreshContent()
1347
+
1348
+ const soularrConfig = this.config.apps.find((a) => a.id === "soularr" && a.enabled)
1349
+ if (!soularrConfig) {
1350
+ this.updateStep("Soularr", "skipped", "Not enabled")
1351
+ this.refreshContent()
1352
+ return
1353
+ }
1354
+
1355
+ // Check dependencies
1356
+ const lidarrConfig = this.config.apps.find((a) => a.id === "lidarr" && a.enabled)
1357
+ const slskdConfig = this.config.apps.find((a) => a.id === "slskd" && a.enabled)
1358
+
1359
+ if (!lidarrConfig || !slskdConfig) {
1360
+ this.updateStep("Soularr", "skipped", "Requires Lidarr & Slskd")
1361
+ this.refreshContent()
1362
+ return
1363
+ }
1364
+
1365
+ try {
1366
+ // Generate soularr config.ini
1367
+ const configContent = generateSoularrConfig(this.config)
1368
+ const configPath = getSoularrConfigPath(this.config.rootDir)
1369
+
1370
+ // Ensure directory exists
1371
+ const configDir = dirname(configPath)
1372
+ if (!existsSync(configDir)) {
1373
+ await mkdir(configDir, { recursive: true })
1374
+ }
1375
+
1376
+ // Write config file
1377
+ await writeFile(configPath, configContent)
1378
+ debugLog("FullAutoSetup", `Generated soularr config at ${configPath}`)
1379
+
1380
+ this.updateStep("Soularr", "success", "Config generated")
1381
+ } catch (e) {
1382
+ this.updateStep("Soularr", "error", `${e}`)
1383
+ }
1384
+ this.refreshContent()
1385
+ }
1386
+
1287
1387
  private updateStep(name: string, status: SetupStep["status"], message?: string): void {
1288
1388
  const step = this.steps.find((s) => s.name === name)
1289
1389
  if (step) {
@@ -11,6 +11,7 @@ import { ArrApiClient } from "../../api/arr-api"
11
11
  import { QualityProfileClient } from "../../api/quality-profile-api"
12
12
  import { CustomFormatClient, getCFNamesForCategories } from "../../api/custom-format-api"
13
13
  import { getPresetsForApp, TRaSHProfilePreset } from "../../data/trash-profiles"
14
+ import { LIDARR_CUSTOM_FORMATS } from "../../data/lidarr-custom-formats"
14
15
  import { readEnvSync } from "../../utils/env"
15
16
  import { debugLog } from "../../utils/debug"
16
17
 
@@ -63,12 +64,14 @@ export class TRaSHProfileSetup extends BoxRenderable {
63
64
  this.pageContainer = pageContainer
64
65
 
65
66
  // Get enabled *arr apps that support quality profiles
66
- this.availableApps = config.apps.filter((a) => a.enabled && ["radarr", "sonarr"].includes(a.id)).map((a) => a.id)
67
+ this.availableApps = config.apps
68
+ .filter((a) => a.enabled && ["radarr", "sonarr", "lidarr"].includes(a.id))
69
+ .map((a) => a.id)
67
70
 
68
71
  // Initialize selections
69
72
  this.availableApps.forEach((id) => {
70
73
  this.selectedApps.set(id, true)
71
- const presets = getPresetsForApp(id as "radarr" | "sonarr")
74
+ const presets = getPresetsForApp(id as "radarr" | "sonarr" | "lidarr")
72
75
  if (presets.length > 0) {
73
76
  this.selectedProfiles.set(id, presets[0].id)
74
77
  }
@@ -136,7 +139,7 @@ export class TRaSHProfileSetup extends BoxRenderable {
136
139
  private handleSelectProfilesKeys(key: KeyEvent): void {
137
140
  const selectedAppIds = this.availableApps.filter((id) => this.selectedApps.get(id))
138
141
  const app = selectedAppIds[this.currentIndex]
139
- const presets = getPresetsForApp(app as "radarr" | "sonarr")
142
+ const presets = getPresetsForApp(app as "radarr" | "sonarr" | "lidarr")
140
143
 
141
144
  if (key.name === "up") {
142
145
  const current = this.selectedProfiles.get(app)
@@ -178,7 +181,7 @@ export class TRaSHProfileSetup extends BoxRenderable {
178
181
  for (const appId of selectedAppIds) {
179
182
  const appDef = getApp(appId)
180
183
  const profileId = this.selectedProfiles.get(appId)
181
- const preset = getPresetsForApp(appId as "radarr" | "sonarr").find((p) => p.id === profileId)
184
+ const preset = getPresetsForApp(appId as "radarr" | "sonarr" | "lidarr").find((p) => p.id === profileId)
182
185
 
183
186
  if (!appDef || !preset) continue
184
187
 
@@ -223,28 +226,36 @@ export class TRaSHProfileSetup extends BoxRenderable {
223
226
  if (!apiKey) throw new Error("API key not found - run Extract API Keys first")
224
227
 
225
228
  const port = this.config.apps.find((a) => a.id === appId)?.port || appDef.defaultPort
226
- const qpClient = new QualityProfileClient("localhost", port, apiKey)
227
- const cfClient = new CustomFormatClient("localhost", port, apiKey)
228
-
229
- // Import Custom Formats first
230
- const cfCategories = ["unwanted", "misc"]
231
- if (preset.id.includes("uhd") || preset.id.includes("2160")) {
232
- cfCategories.push("hdr")
233
- }
234
- if (preset.id.includes("remux")) {
235
- cfCategories.push("audio")
229
+ const apiVersion = appDef.rootFolder?.apiVersion || "v3"
230
+ const qpClient = new QualityProfileClient("localhost", port, apiKey, apiVersion)
231
+ const cfClient = new CustomFormatClient("localhost", port, apiKey, apiVersion)
232
+
233
+ // Import Custom Formats - Lidarr uses Davo's guide formats, Radarr/Sonarr use TRaSH
234
+ if (appId === "lidarr") {
235
+ // Import Lidarr custom formats from Davo's Community Guide
236
+ await cfClient.importCustomFormats(LIDARR_CUSTOM_FORMATS)
237
+ } else {
238
+ // Import TRaSH custom formats for Radarr/Sonarr
239
+ const cfCategories = ["unwanted", "misc"]
240
+ if (preset.id.includes("uhd") || preset.id.includes("2160")) {
241
+ cfCategories.push("hdr")
242
+ }
243
+ if (preset.id.includes("remux")) {
244
+ cfCategories.push("audio")
245
+ }
246
+ const cfNames = getCFNamesForCategories(appId as "radarr" | "sonarr", cfCategories)
247
+ const { cfs } = await CustomFormatClient.fetchTRaSHCustomFormats(appId as "radarr" | "sonarr", cfNames)
248
+ await cfClient.importCustomFormats(cfs)
236
249
  }
237
250
 
238
- const cfNames = getCFNamesForCategories(appId as "radarr" | "sonarr", cfCategories)
239
- const { cfs } = await CustomFormatClient.fetchTRaSHCustomFormats(appId as "radarr" | "sonarr", cfNames)
240
- await cfClient.importCustomFormats(cfs)
241
-
242
251
  // Create quality profile
243
252
  await qpClient.createTRaSHProfile(preset.name, preset.cutoffQuality, preset.allowedQualities, preset.cfScores)
244
253
 
245
- // Configure naming scheme
246
- const arrClient = new ArrApiClient("localhost", port, apiKey, appDef.rootFolder?.apiVersion || "v3")
247
- await arrClient.configureTRaSHNaming(appId as "radarr" | "sonarr")
254
+ // Configure naming scheme (skip for Lidarr - different API structure)
255
+ if (appId !== "lidarr") {
256
+ const arrClient = new ArrApiClient("localhost", port, apiKey, apiVersion)
257
+ await arrClient.configureTRaSHNaming(appId as "radarr" | "sonarr")
258
+ }
248
259
  }
249
260
 
250
261
  private refreshContent(): void {
@@ -301,7 +312,7 @@ export class TRaSHProfileSetup extends BoxRenderable {
301
312
 
302
313
  selectedAppIds.forEach((appId, appIdx) => {
303
314
  const app = getApp(appId)
304
- const presets = getPresetsForApp(appId as "radarr" | "sonarr")
315
+ const presets = getPresetsForApp(appId as "radarr" | "sonarr" | "lidarr")
305
316
  const selectedPresetId = this.selectedProfiles.get(appId)
306
317
  const isCurrent = appIdx === this.currentIndex
307
318