@muhammedaksam/easiarr 1.1.2 → 1.1.3

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.3",
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",
@@ -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
@@ -1284,6 +1297,92 @@ export class FullAutoSetup extends BoxRenderable {
1284
1297
  this.refreshContent()
1285
1298
  }
1286
1299
 
1300
+ private async setupSlskd(): Promise<void> {
1301
+ this.updateStep("Slskd", "running")
1302
+ this.refreshContent()
1303
+
1304
+ const slskdConfig = this.config.apps.find((a) => a.id === "slskd" && a.enabled)
1305
+ if (!slskdConfig) {
1306
+ this.updateStep("Slskd", "skipped", "Not enabled")
1307
+ this.refreshContent()
1308
+ return
1309
+ }
1310
+
1311
+ try {
1312
+ // Generate slskd.yml config with auto-generated API key
1313
+ const { yaml, apiKey } = generateSlskdConfig(this.config)
1314
+ const configPath = getSlskdConfigPath(this.config.rootDir)
1315
+
1316
+ // Ensure config directory exists
1317
+ const configDir = dirname(configPath)
1318
+ if (!existsSync(configDir)) {
1319
+ await mkdir(configDir, { recursive: true })
1320
+ }
1321
+
1322
+ // Create slskd download directories (like qBittorrent categories)
1323
+ const slskdDownloadsDir = `${this.config.rootDir}/data/slskd_downloads`
1324
+ await mkdir(`${slskdDownloadsDir}/incomplete`, { recursive: true })
1325
+ await mkdir(`${slskdDownloadsDir}/complete`, { recursive: true })
1326
+ debugLog("FullAutoSetup", `Created slskd download directories at ${slskdDownloadsDir}`)
1327
+
1328
+ // Always write slskd.yml - Docker creates a commented-out example that we need to replace
1329
+ await writeFile(configPath, yaml)
1330
+ debugLog("FullAutoSetup", `Generated slskd.yml at ${configPath}`)
1331
+
1332
+ // Save API key to .env for Homepage widget and Soularr
1333
+ await updateEnv({ API_KEY_SLSKD: apiKey })
1334
+ this.env["API_KEY_SLSKD"] = apiKey
1335
+
1336
+ this.updateStep("Slskd", "success", "Config generated, API key saved")
1337
+ } catch (e) {
1338
+ this.updateStep("Slskd", "error", `${e}`)
1339
+ }
1340
+ this.refreshContent()
1341
+ }
1342
+
1343
+ private async setupSoularr(): Promise<void> {
1344
+ this.updateStep("Soularr", "running")
1345
+ this.refreshContent()
1346
+
1347
+ const soularrConfig = this.config.apps.find((a) => a.id === "soularr" && a.enabled)
1348
+ if (!soularrConfig) {
1349
+ this.updateStep("Soularr", "skipped", "Not enabled")
1350
+ this.refreshContent()
1351
+ return
1352
+ }
1353
+
1354
+ // Check dependencies
1355
+ const lidarrConfig = this.config.apps.find((a) => a.id === "lidarr" && a.enabled)
1356
+ const slskdConfig = this.config.apps.find((a) => a.id === "slskd" && a.enabled)
1357
+
1358
+ if (!lidarrConfig || !slskdConfig) {
1359
+ this.updateStep("Soularr", "skipped", "Requires Lidarr & Slskd")
1360
+ this.refreshContent()
1361
+ return
1362
+ }
1363
+
1364
+ try {
1365
+ // Generate soularr config.ini
1366
+ const configContent = generateSoularrConfig(this.config)
1367
+ const configPath = getSoularrConfigPath(this.config.rootDir)
1368
+
1369
+ // Ensure directory exists
1370
+ const configDir = dirname(configPath)
1371
+ if (!existsSync(configDir)) {
1372
+ await mkdir(configDir, { recursive: true })
1373
+ }
1374
+
1375
+ // Write config file
1376
+ await writeFile(configPath, configContent)
1377
+ debugLog("FullAutoSetup", `Generated soularr config at ${configPath}`)
1378
+
1379
+ this.updateStep("Soularr", "success", "Config generated")
1380
+ } catch (e) {
1381
+ this.updateStep("Soularr", "error", `${e}`)
1382
+ }
1383
+ this.refreshContent()
1384
+ }
1385
+
1287
1386
  private updateStep(name: string, status: SetupStep["status"], message?: string): void {
1288
1387
  const step = this.steps.find((s) => s.name === name)
1289
1388
  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