diff --git a/README.md b/README.md index 9c93c0d..721fd42 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A unified download manager for aMule, rTorrent, qBittorrent, Deluge, and Transmi ### Integrations - **Prowlarr Search** - Search torrents across multiple indexers (results go to any connected BitTorrent client) -- **Sonarr/Radarr** - Torznab indexer and qBittorrent-compatible API for aMule +- **Sonarr/Radarr/Lidarr/Readarr** - Torznab indexer and qBittorrent-compatible API for aMule - **Push Notifications** - Apprise integration for 80+ notification services - **Custom Event Scripts** - Run your own scripts on download events @@ -132,7 +132,7 @@ Open `http://localhost:4000` and complete the setup wizard. | [Notifications](./docs/NOTIFICATIONS.md) | Push notifications via Apprise (80+ services) | | [Custom Scripting](./scripts/README.md) | Run custom scripts on download events | | [GeoIP Setup](./docs/GEOIP.md) | Display peer locations with MaxMind databases | -| [Sonarr/Radarr Integration](./docs/INTEGRATIONS.md) | Complete guide for *arr applications setup | +| [*arr Integration](./docs/INTEGRATIONS.md) | Complete guide for Sonarr, Radarr, Lidarr, Readarr, and other *arr apps | | [User Management](./docs/USERS.md) | Authentication, capabilities, SSO, and API keys | | [API Reference](./docs/API.md) | REST API and WebSocket protocol | | [Development Guide](./docs/DEVELOPMENT.md) | Building, project structure, contributing | @@ -199,7 +199,7 @@ Open `http://localhost:4000` and complete the setup wizard. - Test your service configuration via the Notifications page - See [Notifications Guide](./docs/NOTIFICATIONS.md) for setup details -**Sonarr/Radarr can't find downloaded files?** +**Sonarr/Radarr/Lidarr/Readarr can't find downloaded files?** - Configure categories with correct download paths - Set up Remote Path Mappings if using Docker - See [Integration Guide](./docs/INTEGRATIONS.md) for details diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f0263c3..e0b80be 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -14,7 +14,7 @@ This guide covers all configuration options for aMuTorrent. > > **Prowlarr:** Search torrents directly from the web UI. See [Prowlarr Setup](./PROWLARR.md). > -> ***arr Apps:** Use aMuTorrent as a Torznab indexer and qBittorrent-compatible download client for Sonarr, Radarr, and other *arr applications. See [*arr Integration](./INTEGRATIONS.md). +> ***arr Apps:** Use aMuTorrent as a Torznab indexer and qBittorrent-compatible download client for Sonarr, Radarr, Lidarr, Readarr, and other *arr applications. See [*arr Integration](./INTEGRATIONS.md). > > **Notifications:** Get notified when downloads complete or other events occur. See [Notifications](./NOTIFICATIONS.md). > @@ -34,7 +34,7 @@ When you first access the web interface (or if no configuration exists), an inte 2. **Security** - Configure web interface authentication (password protection) 3. **Download Clients** - Configure aMule, rTorrent, qBittorrent, Deluge, and/or Transmission connections (with testing) 4. **Directories** - Set data, logs, and GeoIP directories -5. **Integrations** - Optionally enable Prowlarr, Sonarr, and Radarr +5. **Integrations** - Optionally enable Prowlarr, Sonarr, Radarr, Lidarr, and Readarr 6. **Review & Save** - Test all settings and save configuration The wizard will: @@ -60,7 +60,7 @@ When authentication is enabled, the password must meet these requirements: After initial setup, access the Settings page anytime via the sidebar (desktop) or bottom navigation bar (mobile). The Settings page allows you to: - View and edit all configuration options -- Test individual configuration sections (aMule, rTorrent, qBittorrent, Deluge, Transmission, Directories, Prowlarr, Sonarr, Radarr) +- Test individual configuration sections (aMule, rTorrent, qBittorrent, Deluge, Transmission, slskd, Directories, Prowlarr, Sonarr, Radarr, Lidarr, Readarr) - Test all configuration at once before saving - Enable/disable integrations with toggle switches @@ -94,6 +94,8 @@ Sensitive fields include: - `PROWLARR_API_KEY` - Prowlarr API key - `SONARR_API_KEY` - Sonarr API key - `RADARR_API_KEY` - Radarr API key + - `LIDARR_API_KEY` - Lidarr API key + - `READARR_API_KEY` - Readarr API key When these are set via environment variables: - The environment variable **always takes precedence** @@ -211,6 +213,16 @@ services: - RADARR_API_KEY=your_api_key # Locks UI editing - RADARR_SEARCH_INTERVAL_HOURS=6 + # Lidarr Integration (optional) + - LIDARR_URL=http://lidarr:8686 + - LIDARR_API_KEY=your_api_key # Locks UI editing + - LIDARR_SEARCH_INTERVAL_HOURS=6 + + # Readarr Integration (optional) + - READARR_URL=http://readarr:8787 + - READARR_API_KEY=your_api_key # Locks UI editing + - READARR_SEARCH_INTERVAL_HOURS=6 + # Skip wizard (optional - only if all settings provided) - SKIP_SETUP_WIZARD=false ``` @@ -326,6 +338,22 @@ services: | `RADARR_API_KEY` | - | Radarr API key (locks UI editing) | | `RADARR_SEARCH_INTERVAL_HOURS` | `6` | Hours between automatic searches | +#### Lidarr Integration + +| Variable | Default | Description | +|----------|---------|-------------| +| `LIDARR_URL` | - | Lidarr base URL (auto-enables integration) | +| `LIDARR_API_KEY` | - | Lidarr API key (locks UI editing) | +| `LIDARR_SEARCH_INTERVAL_HOURS` | `6` | Hours between automatic searches | + +#### Readarr Integration + +| Variable | Default | Description | +|----------|---------|-------------| +| `READARR_URL` | - | Readarr base URL (auto-enables integration) | +| `READARR_API_KEY` | - | Readarr API key (locks UI editing) | +| `READARR_SEARCH_INTERVAL_HOURS` | `6` | Hours between automatic searches | + #### Download History | Variable | Default | Description | diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 9fd8b60..97aee59 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -20,23 +20,23 @@ aMuTorrent integrates with any *arr application (Sonarr, Radarr, Lidarr, Readarr aMuTorrent provides two APIs for *arr integration: -1. **Torznab Indexer API** - Allows Sonarr/Radarr to search the ED2K network -2. **qBittorrent-Compatible Download Client API** - Allows Sonarr/Radarr to manage downloads and import completed files +1. **Torznab Indexer API** - Allows Sonarr/Radarr/Lidarr/Readarr to search the ED2K network +2. **qBittorrent-Compatible Download Client API** - Allows Sonarr/Radarr/Lidarr/Readarr to manage downloads and import completed files --- ## How It Works -1. **Searching:** When Sonarr/Radarr searches for content, they query our Torznab API, which performs an ED2K search and returns results -2. **Downloading:** When a release is selected, Sonarr/Radarr sends the ED2K link to our qBittorrent-compatible API -3. **Monitoring:** Sonarr/Radarr monitors download progress via the Queue page -4. **Importing:** Once complete, Sonarr/Radarr imports the file from the download directory +1. **Searching:** When Sonarr/Radarr/Lidarr/Readarr searches for content, they query our Torznab API, which performs an ED2K search and returns results +2. **Downloading:** When a release is selected, Sonarr/Radarr/Lidarr/Readarr sends the ED2K link to our qBittorrent-compatible API +3. **Monitoring:** Sonarr/Radarr/Lidarr/Readarr monitors download progress via the Queue page +4. **Importing:** Once complete, Sonarr/Radarr/Lidarr/Readarr imports the file from the download directory --- ## Step 1: Configure Categories -**This step is critical!** Categories determine where files are downloaded, and Sonarr/Radarr need to know these paths. +**This step is critical!** Categories determine where files are downloaded, and Sonarr/Radarr/Lidarr/Readarr need to know these paths. ### In aMuTorrent: @@ -52,6 +52,14 @@ aMuTorrent provides two APIs for *arr integration: - **Title:** `radarr` - **Path:** `/downloads/radarr` (or your preferred path) +**For Lidarr (Music):** +- **Title:** `lidarr` +- **Path:** `/downloads/lidarr` (or your preferred path) + +**For Readarr (Books):** +- **Title:** `readarr` +- **Path:** `/downloads/readarr` (or your preferred path) + > **Important:** Remember these exact category names and paths - you'll need them when configuring the download client. --- @@ -60,7 +68,7 @@ aMuTorrent provides two APIs for *arr integration: The Torznab indexer allows *arr applications to search the ED2K network. -### For Sonarr: +### For Sonarr / Radarr / Lidarr / Readarr: 1. Go to **Settings** → **Indexers** 2. Click **+** (Add Indexer) @@ -79,28 +87,28 @@ The Torznab indexer allows *arr applications to search the ED2K network. 5. Click **Test** to verify connection 6. Click **Save** -### For Radarr: - -1. Go to **Settings** → **Indexers** -2. Click **+** (Add Indexer) -3. Select **Torznab** → **Custom** -4. Configure: +Use the same Torznab endpoint in all *arr apps: | Field | Value | |-------|-------| | **Name** | `aMule` (or any name) | | **URL** | `http://YOUR-SERVER:4000/indexer/amule/api` | | **API Key** | Your web UI password (see note below) | -| **Categories** | 2000 (Movies) or leave default | | **Enable Automatic Search** | Your preference | | **Enable Interactive Search** | Yes | -5. Click **Test** to verify connection -6. Click **Save** +Suggested category presets by app: + +| App | Suggested Torznab Category | +|-----|----------------------------| +| Sonarr | 5000 (TV) | +| Radarr | 2000 (Movies) | +| Lidarr | 3000 (Audio) | +| Readarr | 7000 (Books) | > **Note:** Replace `YOUR-SERVER` with your actual server IP/hostname. If running in Docker, use the container name or `host.docker.internal`. -> **Authentication:** If web UI authentication is enabled, the **API Key** field is required. Use your personal API key (found in Settings → Sonarr/Radarr integration info). If authentication is disabled, leave the API Key field empty. +> **Authentication:** If web UI authentication is enabled, the **API Key** field is required. Use your personal API key (found in Settings → *arr integration info). If authentication is disabled, leave the API Key field empty. --- @@ -108,7 +116,7 @@ The Torznab indexer allows *arr applications to search the ED2K network. The qBittorrent-compatible API allows *arr applications to manage downloads. -### For Sonarr: +### For Sonarr / Radarr / Lidarr / Readarr: 1. Go to **Settings** → **Download Clients** 2. Click **+** (Add Download Client) @@ -128,12 +136,7 @@ The qBittorrent-compatible API allows *arr applications to manage downloads. 5. Click **Test** to verify connection 6. Click **Save** -### For Radarr: - -1. Go to **Settings** → **Download Clients** -2. Click **+** (Add Download Client) -3. Select **qBittorrent** -4. Configure: +Use the same download client endpoint in all *arr apps: | Field | Value | |-------|-------| @@ -142,11 +145,16 @@ The qBittorrent-compatible API allows *arr applications to manage downloads. | **Port** | `4000` | | **Username** | Any value, e.g. `admin` (see note below) | | **Password** | Your web UI password (see note below) | -| **Category** | `radarr` (must match category created in Step 1) | | **Remove Completed** | Your preference | -5. Click **Test** to verify connection -6. Click **Save** +Set category by app (must match Step 1): + +| App | Category | +|-----|----------| +| Sonarr | `sonarr` | +| Radarr | `radarr` | +| Lidarr | `lidarr` | +| Readarr | `readarr` | > **Authentication:** If web UI authentication is enabled, fill **either** the API Key field (preferred, newer Sonarr/Radarr) **or** the Username + Password fields — see the next section. @@ -197,13 +205,13 @@ services: With this setup, aMule and *arr containers all see `/downloads` as the same directory on the host (`./data/aMule/incoming`). -> **Note:** The web controller doesn't need access to the downloads directory - only aMule (which does the actual downloading) and Sonarr/Radarr (which import the files) need it. +> **Note:** The web controller doesn't need access to the downloads directory - only aMule (which does the actual downloading) and Sonarr/Radarr/Lidarr/Readarr (which import the files) need it. ### Solution 2: Remote Path Mappings -If containers use different internal paths for the same host directory, configure **Remote Path Mappings** in Sonarr/Radarr. +If containers use different internal paths for the same host directory, configure **Remote Path Mappings** in Sonarr/Radarr/Lidarr/Readarr. -**When do you need this?** When aMule and Sonarr/Radarr mount the same host folder to *different* container paths. +**When do you need this?** When aMule and Sonarr/Radarr/Lidarr/Readarr mount the same host folder to *different* container paths. **Example Setup:** @@ -216,7 +224,7 @@ Sonarr container: mounted as /data/downloads When aMule finishes downloading, it reports the file path as `/downloads/sonarr/show.mkv`. But Sonarr sees that same file as `/data/downloads/sonarr/show.mkv`. Remote Path Mapping tells Sonarr how to translate the path. -**Configure in Sonarr/Radarr:** +**Configure in Sonarr/Radarr/Lidarr/Readarr:** 1. Go to **Settings** → **Download Clients** 2. Scroll to **Remote Path Mappings** @@ -247,9 +255,9 @@ You can configure automatic searches to periodically check for missing content. ### In aMuTorrent: 1. Go to **Settings** -2. Enable **Sonarr Integration** and/or **Radarr Integration** +2. Enable **Sonarr Integration**, **Radarr Integration**, **Lidarr Integration**, and/or **Readarr Integration** 3. Configure: - - **URL:** `http://YOUR-SERVER:8989` (Sonarr) or `http://YOUR-SERVER:7878` (Radarr) + - **URL:** `http://YOUR-SERVER:8989` (Sonarr), `http://YOUR-SERVER:7878` (Radarr), `http://YOUR-SERVER:8686` (Lidarr), or `http://YOUR-SERVER:8787` (Readarr) - **API Key:** Found in *arr Settings → General → Security - **Search Interval:** Hours between automatic searches (e.g., `6`) @@ -258,13 +266,13 @@ You can configure automatic searches to periodically check for missing content. ### What This Does: At the configured interval, the Web Controller will: -1. Connect to Sonarr/Radarr API -2. Trigger a search for missing episodes/movies -3. Sonarr/Radarr will then query the Torznab indexer for results +1. Connect to Sonarr/Radarr/Lidarr/Readarr API +2. Trigger a search for missing episodes/movies/albums/books +3. Sonarr/Radarr/Lidarr/Readarr will then query the Torznab indexer for results ### Required: Enable Automatic Search on the Indexer -For this feature to work, you **must** enable **Automatic Search** on the aMule indexer in Sonarr/Radarr: +For this feature to work, you **must** enable **Automatic Search** on the aMule indexer in Sonarr/Radarr/Lidarr/Readarr: 1. Go to **Settings** → **Indexers** 2. Edit the aMule indexer @@ -306,6 +314,6 @@ ED2K servers have flood protection that can temporarily ban clients making too m ### Automatic search not triggering 1. Verify API key is correct in Settings -2. Check Sonarr/Radarr URL is accessible from Web Controller +2. Check Sonarr/Radarr/Lidarr/Readarr URL is accessible from Web Controller 3. Verify search interval is set (not 0) 4. Check server logs for errors \ No newline at end of file diff --git a/server/lib/configTester.js b/server/lib/configTester.js index 71dacb4..14b03d9 100644 --- a/server/lib/configTester.js +++ b/server/lib/configTester.js @@ -408,37 +408,46 @@ async function testArrAPI(serviceName, url, apiKey) { try { // Remove trailing slash from URL const baseUrl = url.replace(/\/$/, ''); - const endpoint = `${baseUrl}/api/v3/system/status`; - - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'X-Api-Key': apiKey - }, - signal: AbortSignal.timeout(10000) // 10 second timeout - }); - - result.reachable = true; + const endpoints = [ + `${baseUrl}/api/v3/system/status`, + `${baseUrl}/api/v1/system/status` + ]; + + let lastHttpError = null; + for (const endpoint of endpoints) { + const response = await fetch(endpoint, { + method: 'GET', + headers: { + 'X-Api-Key': apiKey + }, + signal: AbortSignal.timeout(10000) // 10 second timeout + }); + + result.reachable = true; + + if (response.status === 401 || response.status === 403) { + result.error = 'Authentication failed - invalid API key'; + return result; + } - if (response.status === 401 || response.status === 403) { - result.error = 'Authentication failed - invalid API key'; - return result; - } + if (!response.ok) { + const text = await response.text(); + lastHttpError = `HTTP ${response.status}: ${text}`; + continue; + } - if (!response.ok) { - const text = await response.text(); - result.error = `HTTP ${response.status}: ${text}`; - return result; - } + const data = await response.json(); + result.authenticated = true; - const data = await response.json(); - result.authenticated = true; + if (data.version) { + result.version = data.version; + } - if (data.version) { - result.version = data.version; + result.success = true; + return result; } - result.success = true; + result.error = lastHttpError || `${serviceName} API did not return a supported /system/status response`; return result; } catch (err) { result.error = classifyNetworkError(err); @@ -466,6 +475,26 @@ async function testRadarrAPI(url, apiKey) { return testArrAPI('Radarr', url, apiKey); } +/** + * Test Lidarr API connection + * @param {string} url - Lidarr URL + * @param {string} apiKey - Lidarr API key + * @returns {Promise<{success: boolean, reachable: boolean, authenticated: boolean, version: string|null, error: string|null}>} + */ +async function testLidarrAPI(url, apiKey) { + return testArrAPI('Lidarr', url, apiKey); +} + +/** + * Test Readarr API connection + * @param {string} url - Readarr URL + * @param {string} apiKey - Readarr API key + * @returns {Promise<{success: boolean, reachable: boolean, authenticated: boolean, version: string|null, error: string|null}>} + */ +async function testReadarrAPI(url, apiKey) { + return testArrAPI('Readarr', url, apiKey); +} + /** * Test Prowlarr API connection * @param {string} url - Prowlarr URL @@ -681,5 +710,7 @@ module.exports = { testTransmissionConnection, testSonarrAPI, testRadarrAPI, + testLidarrAPI, + testReadarrAPI, testProwlarrAPI }; diff --git a/server/modules/arrManager.js b/server/modules/arrManager.js index 835e6ba..85b5208 100644 --- a/server/modules/arrManager.js +++ b/server/modules/arrManager.js @@ -30,7 +30,9 @@ class ArrManager extends BaseModule { // File doesn't exist or is invalid, return default state return { lastSonarrSearchCompleted: null, - lastRadarrSearchCompleted: null + lastRadarrSearchCompleted: null, + lastLidarrSearchCompleted: null, + lastReadarrSearchCompleted: null }; } } @@ -51,10 +53,22 @@ class ArrManager extends BaseModule { state.lastSonarrSearchCompleted = new Date().toISOString(); } else if (service === 'radarr') { state.lastRadarrSearchCompleted = new Date().toISOString(); + } else if (service === 'lidarr') { + state.lastLidarrSearchCompleted = new Date().toISOString(); + } else if (service === 'readarr') { + state.lastReadarrSearchCompleted = new Date().toISOString(); } await this.writeState(state); } + getApiBase(service, url) { + const clean = (url || '').replace(/\/$/, ''); + if (service === 'sonarr' || service === 'radarr') { + return `${clean}/api/v3`; + } + return `${clean}/api/v1`; + } + /** * Generic search function for Sonarr/Radarr content * @param {string} service - 'sonarr' or 'radarr' @@ -64,7 +78,9 @@ class ArrManager extends BaseModule { async searchContent(service, contentId, contentType) { const config_map = { sonarr: { url: config.SONARR_URL, apiKey: config.SONARR_API_KEY, command: 'EpisodeSearch', idKey: 'episodeIds' }, - radarr: { url: config.RADARR_URL, apiKey: config.RADARR_API_KEY, command: 'MoviesSearch', idKey: 'movieIds' } + radarr: { url: config.RADARR_URL, apiKey: config.RADARR_API_KEY, command: 'MoviesSearch', idKey: 'movieIds' }, + lidarr: { url: config.LIDARR_URL, apiKey: config.LIDARR_API_KEY, command: 'AlbumSearch', idKey: 'albumIds' }, + readarr: { url: config.READARR_URL, apiKey: config.READARR_API_KEY, command: 'BookSearch', idKey: 'bookIds' } }; const cfg = config_map[service]; @@ -73,8 +89,10 @@ class ArrManager extends BaseModule { return; } + const apiBase = this.getApiBase(service, cfg.url); + try { - const result = await this.fetchJson(`${cfg.url}/api/v3/command`, { + const result = await this.fetchJson(`${apiBase}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -104,6 +122,16 @@ class ArrManager extends BaseModule { return this.searchContent('radarr', movieId, 'Movie'); } + // Search for a specific album in Lidarr + async searchAlbum(albumId) { + return this.searchContent('lidarr', albumId, 'Album'); + } + + // Search for a specific book in Readarr + async searchBook(bookId) { + return this.searchContent('readarr', bookId, 'Book'); + } + // Helper: fetch JSON with error handling async fetchJson(url, options = {}) { const resp = await fetch(url, options); @@ -128,6 +156,16 @@ class ArrManager extends BaseModule { url: config.RADARR_URL, apiKey: config.RADARR_API_KEY, searchInterval: config.RADARR_SEARCH_INTERVAL_HOURS + }, + lidarr: { + url: config.LIDARR_URL, + apiKey: config.LIDARR_API_KEY, + searchInterval: config.LIDARR_SEARCH_INTERVAL_HOURS + }, + readarr: { + url: config.READARR_URL, + apiKey: config.READARR_API_KEY, + searchInterval: config.READARR_SEARCH_INTERVAL_HOURS } }; return configs[service]; @@ -146,7 +184,7 @@ class ArrManager extends BaseModule { return; } - const statusResp = await fetch(`${cfg.url}/api/v3/command/${commandId}`, { + const statusResp = await fetch(`${this.getApiBase(service, cfg.url)}/command/${commandId}`, { headers: { 'X-Api-Key': cfg.apiKey } }); const statusData = await statusResp.json(); @@ -326,6 +364,42 @@ class ArrManager extends BaseModule { getItemsForContent: async (cfg, content) => [content], // Movies don't have sub-items formatItemLabel: (item) => `${item.title} (${item.year})`, searchFunction: (id) => this.searchMovie(id) + }, + lidarr: { + refreshCommand: 'RefreshArtist', + contentEndpoint: 'artist', + episodeEndpoint: 'album', + queueParam: 'includeUnknownArtistItems=false', + queueIdKey: 'albumId', + contentType: 'album', + contentLabel: 'artists', + itemLabel: 'albums', + getContentId: (item) => item.id, + getItemsForContent: async (cfg, content) => { + return this.fetchJson(`${this.getApiBase(service, cfg.url)}/album?artistId=${content.id}`, { + headers: { 'X-Api-Key': cfg.apiKey } + }); + }, + formatItemLabel: (item) => item.title || item.albumTitle || `Album ${item.id}`, + searchFunction: (id) => this.searchAlbum(id) + }, + readarr: { + refreshCommand: 'RefreshAuthor', + contentEndpoint: 'author', + episodeEndpoint: 'book', + queueParam: 'includeUnknownAuthorItems=false', + queueIdKey: 'bookId', + contentType: 'book', + contentLabel: 'authors', + itemLabel: 'books', + getContentId: (item) => item.id, + getItemsForContent: async (cfg, content) => { + return this.fetchJson(`${this.getApiBase(service, cfg.url)}/book?authorId=${content.id}`, { + headers: { 'X-Api-Key': cfg.apiKey } + }); + }, + formatItemLabel: (item) => item.title || `Book ${item.id}`, + searchFunction: (id) => this.searchBook(id) } }; @@ -346,7 +420,8 @@ class ArrManager extends BaseModule { this.log(`🔄 Triggering ${service} ${svcCfg.refreshCommand}...`); // 1️⃣ Trigger Refresh - const refreshResult = await this.fetchJson(`${cfg.url}/api/v3/command`, { + const apiBase = this.getApiBase(service, cfg.url); + const refreshResult = await this.fetchJson(`${apiBase}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -364,15 +439,15 @@ class ArrManager extends BaseModule { const [allContent, qualityProfiles, queue] = await Promise.all([ // Fetch all content - this.fetchJson(`${cfg.url}/api/v3/${svcCfg.contentEndpoint}`, { + this.fetchJson(`${apiBase}/${svcCfg.contentEndpoint}`, { headers: { 'X-Api-Key': cfg.apiKey } }), // Fetch quality profiles - this.fetchJson(`${cfg.url}/api/v3/qualityprofile`, { + this.fetchJson(`${apiBase}/qualityprofile`, { headers: { 'X-Api-Key': cfg.apiKey } }), // Fetch download queue - this.fetchJson(`${cfg.url}/api/v3/queue?${svcCfg.queueParam}`, { + this.fetchJson(`${apiBase}/queue?${svcCfg.queueParam}`, { headers: { 'X-Api-Key': cfg.apiKey } }) ]); @@ -534,28 +609,35 @@ class ArrManager extends BaseModule { // Check and trigger search if interval has elapsed async checkAndTriggerSearch(service) { const state = await this.readState(); - const intervalMs = service === 'sonarr' - ? hoursToMs(config.SONARR_SEARCH_INTERVAL_HOURS) - : hoursToMs(config.RADARR_SEARCH_INTERVAL_HOURS); + const intervalByService = { + sonarr: hoursToMs(config.SONARR_SEARCH_INTERVAL_HOURS), + radarr: hoursToMs(config.RADARR_SEARCH_INTERVAL_HOURS), + lidarr: hoursToMs(config.LIDARR_SEARCH_INTERVAL_HOURS), + readarr: hoursToMs(config.READARR_SEARCH_INTERVAL_HOURS) + }; + const intervalMs = intervalByService[service] || 0; if(intervalMs === 0) { return; } - const lastCompleted = service === 'sonarr' - ? state.lastSonarrSearchCompleted - : state.lastRadarrSearchCompleted; + const lastMap = { + sonarr: state.lastSonarrSearchCompleted, + radarr: state.lastRadarrSearchCompleted, + lidarr: state.lastLidarrSearchCompleted, + readarr: state.lastReadarrSearchCompleted + }; + const lastCompleted = lastMap[service] || null; const now = Date.now(); // If never run before, run immediately if (!lastCompleted) { this.log(`🚀 ${service.charAt(0).toUpperCase() + service.slice(1)} search has never run, triggering now...`); - if (service === 'sonarr') { - await this.triggerSonarrMissingSearch(); - } else { - await this.triggerRadarrMissingSearch(); - } + if (service === 'sonarr') await this.triggerSonarrMissingSearch(); + else if (service === 'radarr') await this.triggerRadarrMissingSearch(); + else if (service === 'lidarr') await this.triggerLidarrMissingSearch(); + else if (service === 'readarr') await this.triggerReadarrMissingSearch(); return; } @@ -566,14 +648,23 @@ class ArrManager extends BaseModule { if (timeSinceLastRun >= intervalMs) { const hoursOverdue = Math.floor((timeSinceLastRun - intervalMs) / MS_PER_HOUR); this.log(`⏰ ${service.charAt(0).toUpperCase() + service.slice(1)} search interval elapsed (${hoursOverdue}h overdue), triggering now...`); - if (service === 'sonarr') { - await this.triggerSonarrMissingSearch(); - } else { - await this.triggerRadarrMissingSearch(); - } + if (service === 'sonarr') await this.triggerSonarrMissingSearch(); + else if (service === 'radarr') await this.triggerRadarrMissingSearch(); + else if (service === 'lidarr') await this.triggerLidarrMissingSearch(); + else if (service === 'readarr') await this.triggerReadarrMissingSearch(); } } + // Trigger Lidarr to search for missing albums + async triggerLidarrMissingSearch() { + return this.triggerMissingSearch('lidarr'); + } + + // Trigger Readarr to search for missing books + async triggerReadarrMissingSearch() { + return this.triggerMissingSearch('readarr'); + } + // Schedule automatic searches scheduleAutomaticSearches() { // Schedule Sonarr searches if configured @@ -601,6 +692,22 @@ class ArrManager extends BaseModule { // Setup automatic searches (user may have enabled it from the settings panel after initialization) setInterval(() => this.checkAndTriggerSearch('radarr'), minutesToMs(10)); + + if (config.LIDARR_SEARCH_INTERVAL_HOURS > 0) { + this.log(`⏰ Scheduling Lidarr automatic searches every ${config.LIDARR_SEARCH_INTERVAL_HOURS} hour(s)`); + this.checkAndTriggerSearch('lidarr'); + } else { + this.log('ℹ️ Lidarr automatic search scheduling disabled (set LIDARR_SEARCH_INTERVAL_HOURS > 0 to enable)'); + } + setInterval(() => this.checkAndTriggerSearch('lidarr'), minutesToMs(10)); + + if (config.READARR_SEARCH_INTERVAL_HOURS > 0) { + this.log(`⏰ Scheduling Readarr automatic searches every ${config.READARR_SEARCH_INTERVAL_HOURS} hour(s)`); + this.checkAndTriggerSearch('readarr'); + } else { + this.log('ℹ️ Readarr automatic search scheduling disabled (set READARR_SEARCH_INTERVAL_HOURS > 0 to enable)'); + } + setInterval(() => this.checkAndTriggerSearch('readarr'), minutesToMs(10)); } } diff --git a/server/modules/config.js b/server/modules/config.js index 6c8798e..d09985b 100644 --- a/server/modules/config.js +++ b/server/modules/config.js @@ -44,6 +44,12 @@ const ENV_VAR_MAP = { RADARR_URL: { path: 'integrations.radarr.url', type: 'string', enablesIntegration: 'integrations.radarr.enabled' }, RADARR_API_KEY: { path: 'integrations.radarr.apiKey', type: 'string' }, RADARR_SEARCH_INTERVAL_HOURS: { path: 'integrations.radarr.searchIntervalHours', type: 'int' }, + LIDARR_URL: { path: 'integrations.lidarr.url', type: 'string', enablesIntegration: 'integrations.lidarr.enabled' }, + LIDARR_API_KEY: { path: 'integrations.lidarr.apiKey', type: 'string' }, + LIDARR_SEARCH_INTERVAL_HOURS: { path: 'integrations.lidarr.searchIntervalHours', type: 'int' }, + READARR_URL: { path: 'integrations.readarr.url', type: 'string', enablesIntegration: 'integrations.readarr.enabled' }, + READARR_API_KEY: { path: 'integrations.readarr.apiKey', type: 'string' }, + READARR_SEARCH_INTERVAL_HOURS: { path: 'integrations.readarr.searchIntervalHours', type: 'int' }, PROWLARR_URL: { path: 'integrations.prowlarr.url', type: 'string', enablesIntegration: 'integrations.prowlarr.enabled' }, PROWLARR_API_KEY: { path: 'integrations.prowlarr.apiKey', type: 'string' } }; @@ -55,6 +61,8 @@ const SENSITIVE_PATHS = [ 'server.auth.password', 'integrations.sonarr.apiKey', 'integrations.radarr.apiKey', + 'integrations.lidarr.apiKey', + 'integrations.readarr.apiKey', 'integrations.prowlarr.apiKey' ]; @@ -397,6 +405,18 @@ class Config extends BaseModule { apiKey: '', searchIntervalHours: 6 }, + lidarr: { + enabled: false, + url: '', + apiKey: '', + searchIntervalHours: 6 + }, + readarr: { + enabled: false, + url: '', + apiKey: '', + searchIntervalHours: 6 + }, prowlarr: { enabled: false, url: '', @@ -772,6 +792,26 @@ class Config extends BaseModule { } } + // Validate Lidarr if enabled + if (config.integrations?.lidarr?.enabled) { + if (!config.integrations.lidarr.url) { + errors.push('Lidarr URL is required when Lidarr is enabled'); + } + if (!config.integrations.lidarr.apiKey) { + errors.push('Lidarr API key is required when Lidarr is enabled'); + } + } + + // Validate Readarr if enabled + if (config.integrations?.readarr?.enabled) { + if (!config.integrations.readarr.url) { + errors.push('Readarr URL is required when Readarr is enabled'); + } + if (!config.integrations.readarr.apiKey) { + errors.push('Readarr API key is required when Readarr is enabled'); + } + } + // Validate clients array for duplicate instance IDs (same type+host+port) if (Array.isArray(config.clients)) { const seen = new Map(); // id → entry name/label @@ -1281,6 +1321,42 @@ class Config extends BaseModule { : 0; } + get LIDARR_URL() { + return this.runtimeConfig?.integrations?.lidarr?.enabled + ? this.runtimeConfig.integrations.lidarr.url + : null; + } + + get LIDARR_API_KEY() { + return this.runtimeConfig?.integrations?.lidarr?.enabled + ? this.runtimeConfig.integrations.lidarr.apiKey + : null; + } + + get LIDARR_SEARCH_INTERVAL_HOURS() { + return this.runtimeConfig?.integrations?.lidarr?.enabled + ? this.runtimeConfig.integrations.lidarr.searchIntervalHours + : 0; + } + + get READARR_URL() { + return this.runtimeConfig?.integrations?.readarr?.enabled + ? this.runtimeConfig.integrations.readarr.url + : null; + } + + get READARR_API_KEY() { + return this.runtimeConfig?.integrations?.readarr?.enabled + ? this.runtimeConfig.integrations.readarr.apiKey + : null; + } + + get READARR_SEARCH_INTERVAL_HOURS() { + return this.runtimeConfig?.integrations?.readarr?.enabled + ? this.runtimeConfig.integrations.readarr.searchIntervalHours + : 0; + } + get PROWLARR_URL() { return this.runtimeConfig?.integrations?.prowlarr?.enabled ? this.runtimeConfig.integrations.prowlarr.url diff --git a/server/modules/configAPI.js b/server/modules/configAPI.js index 7e4f682..42c7350 100644 --- a/server/modules/configAPI.js +++ b/server/modules/configAPI.js @@ -90,6 +90,12 @@ class ConfigAPI extends BaseModule { radarrUrl: config.isFromEnv('integrations.radarr.url'), radarrApiKey: config.isFromEnv('integrations.radarr.apiKey'), radarrSearchInterval: config.isFromEnv('integrations.radarr.searchIntervalHours'), + lidarrUrl: config.isFromEnv('integrations.lidarr.url'), + lidarrApiKey: config.isFromEnv('integrations.lidarr.apiKey'), + lidarrSearchInterval: config.isFromEnv('integrations.lidarr.searchIntervalHours'), + readarrUrl: config.isFromEnv('integrations.readarr.url'), + readarrApiKey: config.isFromEnv('integrations.readarr.apiKey'), + readarrSearchInterval: config.isFromEnv('integrations.readarr.searchIntervalHours'), prowlarrUrl: config.isFromEnv('integrations.prowlarr.url'), prowlarrApiKey: config.isFromEnv('integrations.prowlarr.apiKey') }; @@ -105,6 +111,8 @@ class ConfigAPI extends BaseModule { const passwordPaths = [ { new: 'integrations.sonarr.apiKey', current: 'integrations.sonarr.apiKey' }, { new: 'integrations.radarr.apiKey', current: 'integrations.radarr.apiKey' }, + { new: 'integrations.lidarr.apiKey', current: 'integrations.lidarr.apiKey' }, + { new: 'integrations.readarr.apiKey', current: 'integrations.readarr.apiKey' }, { new: 'integrations.prowlarr.apiKey', current: 'integrations.prowlarr.apiKey' } ]; @@ -278,12 +286,12 @@ class ConfigAPI extends BaseModule { /** * POST /api/config/test * Test configuration components - * Body: { amule?, rtorrent?, directories?, sonarr?, radarr?, prowlarr? } + * Body: { amule?, rtorrent?, directories?, sonarr?, radarr?, lidarr?, readarr?, prowlarr? } * Note: If passwords are missing, use current config values */ async testConfig(req, res) { try { - const { amule, rtorrent, directories, sonarr, radarr, prowlarr } = req.body; + const { amule, rtorrent, directories, sonarr, radarr, lidarr, readarr, prowlarr } = req.body; const results = {}; const currentConfig = config.getConfig(); @@ -392,6 +400,22 @@ class ConfigAPI extends BaseModule { this.logTestResult('Radarr API', results.radarr); } + // Test Lidarr if provided and enabled + if (lidarr && lidarr.enabled) { + const apiKey = lidarr.apiKey || currentConfig.integrations?.lidarr?.apiKey; + this.log(`🧪 Testing Lidarr API at ${lidarr.url}...`); + results.lidarr = await configTester.testLidarrAPI(lidarr.url, apiKey); + this.logTestResult('Lidarr API', results.lidarr); + } + + // Test Readarr if provided and enabled + if (readarr && readarr.enabled) { + const apiKey = readarr.apiKey || currentConfig.integrations?.readarr?.apiKey; + this.log(`🧪 Testing Readarr API at ${readarr.url}...`); + results.readarr = await configTester.testReadarrAPI(readarr.url, apiKey); + this.logTestResult('Readarr API', results.readarr); + } + // Test Prowlarr if provided and enabled if (prowlarr && prowlarr.enabled) { const apiKey = prowlarr.apiKey || currentConfig.integrations?.prowlarr?.apiKey; @@ -507,7 +531,11 @@ class ConfigAPI extends BaseModule { for (const mgr of registry.getAll()) { this.log(`🔄 Closing existing ${mgr.displayName} connection (${mgr.instanceId})...`); try { - await mgr.shutdown(); + if (typeof mgr.shutdown === 'function') { + await mgr.shutdown(); + } else if (typeof mgr.cleanup === 'function') { + await mgr.cleanup(); + } } catch (err) { this.warn(`⚠️ Error shutting down ${mgr.instanceId}:`, err.message); } diff --git a/static/components/settings/TestSummary.js b/static/components/settings/TestSummary.js index 24eaffb..e7008ba 100644 --- a/static/components/settings/TestSummary.js +++ b/static/components/settings/TestSummary.js @@ -116,6 +116,26 @@ const TestSummary = ({ testResults, clientTestResults, showDetails = false }) => } } + // Count Lidarr + if (results.lidarr) { + summary.total++; + if (results.lidarr.success === false) { + summary.failed++; + } else if (results.lidarr.success) { + summary.passed++; + } + } + + // Count Readarr + if (results.readarr) { + summary.total++; + if (results.readarr.success === false) { + summary.failed++; + } else if (results.readarr.success) { + summary.passed++; + } + } + // Count Prowlarr if (results.prowlarr) { summary.total++; @@ -195,6 +215,26 @@ const TestSummary = ({ testResults, clientTestResults, showDetails = false }) => }); } + // Lidarr result + if (results.lidarr && !results.lidarr.success) { + detailedResults.push({ + label: 'Lidarr API', + success: false, + message: results.lidarr.message || results.lidarr.error, + warning: false + }); + } + + // Readarr result + if (results.readarr && !results.readarr.success) { + detailedResults.push({ + label: 'Readarr API', + success: false, + message: results.readarr.message || results.readarr.error, + warning: false + }); + } + // Prowlarr result if (results.prowlarr && !results.prowlarr.success) { detailedResults.push({ diff --git a/static/components/views/SettingsView.js b/static/components/views/SettingsView.js index 7040f45..b5e91e3 100644 --- a/static/components/views/SettingsView.js +++ b/static/components/views/SettingsView.js @@ -161,7 +161,9 @@ const SettingsView = () => { } } if ((results.sonarr && results.sonarr.success === false) || - (results.radarr && results.radarr.success === false)) { + (results.radarr && results.radarr.success === false) || + (results.lidarr && results.lidarr.success === false) || + (results.readarr && results.readarr.success === false)) { updates.integrations = true; } if (results.prowlarr && results.prowlarr.success === false) { @@ -194,6 +196,8 @@ const SettingsView = () => { const handleTestDirectories = makeTestHandler('directories', c => c.directories); const handleTestSonarr = makeTestHandler('sonarr', c => c.integrations.sonarr); const handleTestRadarr = makeTestHandler('radarr', c => c.integrations.radarr); + const handleTestLidarr = makeTestHandler('lidarr', c => c.integrations.lidarr); + const handleTestReadarr = makeTestHandler('readarr', c => c.integrations.readarr); const handleTestProwlarr = makeTestHandler('prowlarr', c => c.integrations.prowlarr); // Test Event Script Path @@ -221,6 +225,8 @@ const SettingsView = () => { const payload = { directories: unmasked.directories }; if (unmasked.integrations?.sonarr?.enabled) payload.sonarr = unmasked.integrations.sonarr; if (unmasked.integrations?.radarr?.enabled) payload.radarr = unmasked.integrations.radarr; + if (unmasked.integrations?.lidarr?.enabled) payload.lidarr = unmasked.integrations.lidarr; + if (unmasked.integrations?.readarr?.enabled) payload.readarr = unmasked.integrations.readarr; if (unmasked.integrations?.prowlarr?.enabled) payload.prowlarr = unmasked.integrations.prowlarr; return payload; }; @@ -377,7 +383,9 @@ const SettingsView = () => { const enabledArrs = [ formData.integrations.sonarr?.enabled && 'Sonarr', - formData.integrations.radarr?.enabled && 'Radarr' + formData.integrations.radarr?.enabled && 'Radarr', + formData.integrations.lidarr?.enabled && 'Lidarr', + formData.integrations.readarr?.enabled && 'Readarr' ].filter(Boolean); const arrBadge = enabledArrs.length > 0 ? pill(enabledArrs.join(', ')) : null; @@ -740,7 +748,7 @@ const SettingsView = () => { // *arr Integrations h(ConfigSection, { title: '*arr Integrations', - description: 'Torznab and qBittorrent-compatible API for ED2K, Sonarr and Radarr schedulers', + description: 'Torznab and qBittorrent-compatible API for ED2K, Sonarr, Radarr, Lidarr, and Readarr schedulers', defaultOpen: false, open: openSections.integrations, onToggle: (value) => toggleSection('integrations', value), @@ -849,6 +857,124 @@ const SettingsView = () => { ) ), + // Lidarr scheduler + h('div', { className: 'mb-6' }, + h(EnableToggle, { + enabled: formData.integrations.lidarr?.enabled || false, + onChange: (value) => updateNestedField('integrations', 'lidarr', 'enabled', value), + label: 'Enable Lidarr scheduler', + description: '(Optional) Schedule automatic searches for missing albums via Lidarr API' + }), + formData.integrations.lidarr?.enabled && h('div', { className: 'mt-4 space-y-4' }, + h(ConfigField, { + label: 'Lidarr URL', + description: 'Lidarr server URL (e.g., http://localhost:8686)', + value: formData.integrations.lidarr?.url || '', + onChange: (value) => updateNestedField('integrations', 'lidarr', 'url', value), + placeholder: 'http://localhost:8686', + required: formData.integrations.lidarr?.enabled, + fromEnv: meta?.fromEnv.lidarrUrl + }), + meta?.fromEnv.lidarrApiKey && h(AlertBox, { type: 'warning' }, + h('p', {}, 'Lidarr API key is set via LIDARR_API_KEY environment variable.') + ), + !meta?.fromEnv.lidarrApiKey && h(ConfigField, { + label: 'API Key', + description: 'Lidarr API key (found in Settings → General)', + value: formData.integrations.lidarr?.apiKey || '', + onChange: (value) => updateNestedField('integrations', 'lidarr', 'apiKey', value), + required: formData.integrations.lidarr?.enabled, + fromEnv: meta?.fromEnv.lidarrApiKey + }, + h(PasswordField, { + value: formData.integrations.lidarr?.apiKey || '', + onChange: (value) => updateNestedField('integrations', 'lidarr', 'apiKey', value), + placeholder: 'Enter Lidarr API key', + disabled: meta?.fromEnv.lidarrApiKey + }) + ), + h(ConfigField, { + label: 'Search Interval (hours)', + description: 'Hours between automatic searches (0 = disabled)', + value: formData.integrations.lidarr?.searchIntervalHours || 6, + onChange: (value) => updateNestedField('integrations', 'lidarr', 'searchIntervalHours', value), + type: 'number', + placeholder: '6', + fromEnv: meta?.fromEnv.lidarrSearchInterval + }), + h('div', { className: 'mt-4' }, + h(TestButton, { + onClick: handleTestLidarr, + loading: isTesting, + disabled: !formData.integrations.lidarr?.url || !formData.integrations.lidarr?.apiKey + }, 'Test Lidarr Connection') + ), + testResults?.results?.lidarr && h(TestResultIndicator, { + result: testResults.results.lidarr, + label: 'Lidarr API Test' + }) + ) + ), + + // Readarr scheduler + h('div', { className: 'mb-6' }, + h(EnableToggle, { + enabled: formData.integrations.readarr?.enabled || false, + onChange: (value) => updateNestedField('integrations', 'readarr', 'enabled', value), + label: 'Enable Readarr scheduler', + description: '(Optional) Schedule automatic searches for missing books via Readarr API' + }), + formData.integrations.readarr?.enabled && h('div', { className: 'mt-4 space-y-4' }, + h(ConfigField, { + label: 'Readarr URL', + description: 'Readarr server URL (e.g., http://localhost:8787)', + value: formData.integrations.readarr?.url || '', + onChange: (value) => updateNestedField('integrations', 'readarr', 'url', value), + placeholder: 'http://localhost:8787', + required: formData.integrations.readarr?.enabled, + fromEnv: meta?.fromEnv.readarrUrl + }), + meta?.fromEnv.readarrApiKey && h(AlertBox, { type: 'warning' }, + h('p', {}, 'Readarr API key is set via READARR_API_KEY environment variable.') + ), + !meta?.fromEnv.readarrApiKey && h(ConfigField, { + label: 'API Key', + description: 'Readarr API key (found in Settings → General)', + value: formData.integrations.readarr?.apiKey || '', + onChange: (value) => updateNestedField('integrations', 'readarr', 'apiKey', value), + required: formData.integrations.readarr?.enabled, + fromEnv: meta?.fromEnv.readarrApiKey + }, + h(PasswordField, { + value: formData.integrations.readarr?.apiKey || '', + onChange: (value) => updateNestedField('integrations', 'readarr', 'apiKey', value), + placeholder: 'Enter Readarr API key', + disabled: meta?.fromEnv.readarrApiKey + }) + ), + h(ConfigField, { + label: 'Search Interval (hours)', + description: 'Hours between automatic searches (0 = disabled)', + value: formData.integrations.readarr?.searchIntervalHours || 6, + onChange: (value) => updateNestedField('integrations', 'readarr', 'searchIntervalHours', value), + type: 'number', + placeholder: '6', + fromEnv: meta?.fromEnv.readarrSearchInterval + }), + h('div', { className: 'mt-4' }, + h(TestButton, { + onClick: handleTestReadarr, + loading: isTesting, + disabled: !formData.integrations.readarr?.url || !formData.integrations.readarr?.apiKey + }, 'Test Readarr Connection') + ), + testResults?.results?.readarr && h(TestResultIndicator, { + result: testResults.results.readarr, + label: 'Readarr API Test' + }) + ) + ), + // Radarr scheduler h('div', { className: 'mb-6' }, h(EnableToggle, { diff --git a/static/components/views/SetupWizardView.js b/static/components/views/SetupWizardView.js index 87f871b..6998e14 100644 --- a/static/components/views/SetupWizardView.js +++ b/static/components/views/SetupWizardView.js @@ -105,6 +105,8 @@ const SetupWizardView = ({ onComplete }) => { integrations: { sonarr: { ...defaults.integrations.sonarr }, radarr: { ...defaults.integrations.radarr }, + lidarr: { ...defaults.integrations?.lidarr || { enabled: false, url: '', apiKey: '', searchIntervalHours: 6 } }, + readarr: { ...defaults.integrations?.readarr || { enabled: false, url: '', apiKey: '', searchIntervalHours: 6 } }, prowlarr: { ...defaults.integrations?.prowlarr || { enabled: false, url: '', apiKey: '' } } } }); @@ -271,6 +273,14 @@ const SetupWizardView = ({ onComplete }) => { if (!formData.integrations.radarr.url) errors.push('Radarr URL is required'); if (!formData.integrations.radarr.apiKey && !meta?.fromEnv.radarrApiKey) errors.push('Radarr API key is required'); } + if (formData.integrations.lidarr?.enabled) { + if (!formData.integrations.lidarr.url) errors.push('Lidarr URL is required'); + if (!formData.integrations.lidarr.apiKey && !meta?.fromEnv.lidarrApiKey) errors.push('Lidarr API key is required'); + } + if (formData.integrations.readarr?.enabled) { + if (!formData.integrations.readarr.url) errors.push('Readarr URL is required'); + if (!formData.integrations.readarr.apiKey && !meta?.fromEnv.readarrApiKey) errors.push('Readarr API key is required'); + } if (formData.integrations.prowlarr?.enabled) { if (!formData.integrations.prowlarr.url) errors.push('Prowlarr URL is required'); if (!formData.integrations.prowlarr.apiKey && !meta?.fromEnv.prowlarrApiKey) errors.push('Prowlarr API key is required'); @@ -362,6 +372,12 @@ const SetupWizardView = ({ onComplete }) => { if (formData.integrations.radarr.enabled) { testPayload.radarr = formData.integrations.radarr; } + if (formData.integrations.lidarr?.enabled) { + testPayload.lidarr = formData.integrations.lidarr; + } + if (formData.integrations.readarr?.enabled) { + testPayload.readarr = formData.integrations.readarr; + } if (formData.integrations.prowlarr?.enabled) { testPayload.prowlarr = formData.integrations.prowlarr; } @@ -572,7 +588,7 @@ const SetupWizardView = ({ onComplete }) => { h(Icon, { name: 'cloud', size: 20, className: 'text-blue-600 dark:text-blue-400 mt-0.5' }), h('div', {}, h('p', { className: 'font-medium text-gray-900 dark:text-gray-100' }, 'Integrations (Optional)'), - h('p', { className: 'text-sm text-gray-600 dark:text-gray-400' }, 'Configure Sonarr, Radarr, and Prowlarr integrations') + h('p', { className: 'text-sm text-gray-600 dark:text-gray-400' }, 'Configure Sonarr, Radarr, Lidarr, Readarr, and Prowlarr integrations') ) ) ) @@ -1310,6 +1326,8 @@ const SetupWizardView = ({ onComplete }) => { const IntegrationsStep = () => { const hasAnyIntegration = formData.integrations.sonarr.enabled || formData.integrations.radarr.enabled || + formData.integrations.lidarr?.enabled || + formData.integrations.readarr?.enabled || formData.integrations.prowlarr?.enabled; return h('div', {}, @@ -1416,6 +1434,98 @@ const SetupWizardView = ({ onComplete }) => { ) ), + // Lidarr scheduler + h('div', { className: 'mb-6' }, + h(EnableToggle, { + enabled: formData.integrations.lidarr?.enabled || false, + onChange: (value) => updateNestedField('integrations', 'lidarr', 'enabled', value), + label: 'Enable Lidarr scheduler', + description: '(Optional) Schedule automatic searches for missing albums via Lidarr API' + }), + formData.integrations.lidarr?.enabled && h('div', { className: 'mt-4 space-y-4' }, + h(ConfigField, { + label: 'Lidarr URL', + description: 'Lidarr server URL (e.g., http://localhost:8686)', + value: formData.integrations.lidarr?.url || '', + onChange: (value) => updateNestedField('integrations', 'lidarr', 'url', value), + placeholder: 'http://localhost:8686', + required: formData.integrations.lidarr?.enabled, + fromEnv: meta?.fromEnv.lidarrUrl + }), + meta?.fromEnv.lidarrApiKey && h(AlertBox, { type: 'warning' }, + h('p', {}, 'Lidarr API key is set via environment variable.') + ), + !meta?.fromEnv.lidarrApiKey && h(ConfigField, { + label: 'API Key', + description: 'Lidarr API key (Settings -> General)', + value: formData.integrations.lidarr?.apiKey || '', + onChange: (value) => updateNestedField('integrations', 'lidarr', 'apiKey', value), + required: formData.integrations.lidarr?.enabled + }, + h(PasswordField, { + value: formData.integrations.lidarr?.apiKey || '', + onChange: (value) => updateNestedField('integrations', 'lidarr', 'apiKey', value), + placeholder: 'Enter Lidarr API key', + disabled: meta?.fromEnv.lidarrApiKey + }) + ), + h(ConfigField, { + label: 'Search Interval (hours)', + description: 'Hours between automatic searches (0 = disabled)', + value: formData.integrations.lidarr?.searchIntervalHours || 6, + onChange: (value) => updateNestedField('integrations', 'lidarr', 'searchIntervalHours', value), + type: 'number', + placeholder: '6' + }) + ) + ), + + // Readarr scheduler + h('div', { className: 'mb-6' }, + h(EnableToggle, { + enabled: formData.integrations.readarr?.enabled || false, + onChange: (value) => updateNestedField('integrations', 'readarr', 'enabled', value), + label: 'Enable Readarr scheduler', + description: '(Optional) Schedule automatic searches for missing books via Readarr API' + }), + formData.integrations.readarr?.enabled && h('div', { className: 'mt-4 space-y-4' }, + h(ConfigField, { + label: 'Readarr URL', + description: 'Readarr server URL (e.g., http://localhost:8787)', + value: formData.integrations.readarr?.url || '', + onChange: (value) => updateNestedField('integrations', 'readarr', 'url', value), + placeholder: 'http://localhost:8787', + required: formData.integrations.readarr?.enabled, + fromEnv: meta?.fromEnv.readarrUrl + }), + meta?.fromEnv.readarrApiKey && h(AlertBox, { type: 'warning' }, + h('p', {}, 'Readarr API key is set via environment variable.') + ), + !meta?.fromEnv.readarrApiKey && h(ConfigField, { + label: 'API Key', + description: 'Readarr API key (Settings -> General)', + value: formData.integrations.readarr?.apiKey || '', + onChange: (value) => updateNestedField('integrations', 'readarr', 'apiKey', value), + required: formData.integrations.readarr?.enabled + }, + h(PasswordField, { + value: formData.integrations.readarr?.apiKey || '', + onChange: (value) => updateNestedField('integrations', 'readarr', 'apiKey', value), + placeholder: 'Enter Readarr API key', + disabled: meta?.fromEnv.readarrApiKey + }) + ), + h(ConfigField, { + label: 'Search Interval (hours)', + description: 'Hours between automatic searches (0 = disabled)', + value: formData.integrations.readarr?.searchIntervalHours || 6, + onChange: (value) => updateNestedField('integrations', 'readarr', 'searchIntervalHours', value), + type: 'number', + placeholder: '6' + }) + ) + ), + // Prowlarr summary (configured in rTorrent step) formData.integrations.prowlarr?.enabled && h('div', { className: 'bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mb-6' }, h('h3', { className: 'font-semibold text-gray-900 dark:text-gray-100 mb-2' }, 'Prowlarr (rTorrent torrent search)'), @@ -1430,6 +1540,8 @@ const SetupWizardView = ({ onComplete }) => { disabled: (formData.integrations.sonarr.enabled && (!formData.integrations.sonarr.url || !formData.integrations.sonarr.apiKey)) || (formData.integrations.radarr.enabled && (!formData.integrations.radarr.url || !formData.integrations.radarr.apiKey)) || + (formData.integrations.lidarr?.enabled && (!formData.integrations.lidarr?.url || !formData.integrations.lidarr?.apiKey)) || + (formData.integrations.readarr?.enabled && (!formData.integrations.readarr?.url || !formData.integrations.readarr?.apiKey)) || (formData.integrations.prowlarr?.enabled && (!formData.integrations.prowlarr?.url || !formData.integrations.prowlarr?.apiKey)) }, 'Test Integrations') ), @@ -1443,6 +1555,14 @@ const SetupWizardView = ({ onComplete }) => { result: testResults.results.radarr, label: 'Radarr API Test' }), + testResults.results.lidarr && h(TestResultIndicator, { + result: testResults.results.lidarr, + label: 'Lidarr API Test' + }), + testResults.results.readarr && h(TestResultIndicator, { + result: testResults.results.readarr, + label: 'Readarr API Test' + }), testResults.results.prowlarr && h(TestResultIndicator, { result: testResults.results.prowlarr, label: 'Prowlarr API Test' diff --git a/static/hooks/useSettingsFormData.js b/static/hooks/useSettingsFormData.js index 42a145d..432a7d3 100644 --- a/static/hooks/useSettingsFormData.js +++ b/static/hooks/useSettingsFormData.js @@ -18,6 +18,8 @@ const buildFormData = (cfg) => ({ amuleInstanceId: cfg.integrations?.amuleInstanceId || null, sonarr: { ...cfg.integrations.sonarr }, radarr: { ...cfg.integrations.radarr }, + lidarr: { ...cfg.integrations?.lidarr || { enabled: false, url: '', apiKey: '', searchIntervalHours: 6 } }, + readarr: { ...cfg.integrations?.readarr || { enabled: false, url: '', apiKey: '', searchIntervalHours: 6 } }, prowlarr: { ...cfg.integrations?.prowlarr || { enabled: false, url: '', apiKey: '' } } }, history: { ...cfg.history }, @@ -51,6 +53,12 @@ const getUnmaskedConfig = (config) => { if (unmasked.integrations?.radarr?.apiKey === '********') { delete unmasked.integrations.radarr.apiKey; } + if (unmasked.integrations?.lidarr?.apiKey === '********') { + delete unmasked.integrations.lidarr.apiKey; + } + if (unmasked.integrations?.readarr?.apiKey === '********') { + delete unmasked.integrations.readarr.apiKey; + } if (unmasked.integrations?.prowlarr?.apiKey === '********') { delete unmasked.integrations.prowlarr.apiKey; } @@ -93,6 +101,8 @@ export const useSettingsFormData = ({ currentConfig, clearTestResults }) => { auth: currentConfig.server?.auth?.password || '', sonarr: currentConfig.integrations.sonarr.apiKey, radarr: currentConfig.integrations.radarr.apiKey, + lidarr: currentConfig.integrations?.lidarr?.apiKey || '', + readarr: currentConfig.integrations?.readarr?.apiKey || '', prowlarr: currentConfig.integrations?.prowlarr?.apiKey || '' }); } diff --git a/static/utils/testHelpers.js b/static/utils/testHelpers.js index 572ef8f..89a48a3 100644 --- a/static/utils/testHelpers.js +++ b/static/utils/testHelpers.js @@ -36,6 +36,8 @@ export function hasTestErrors(testResults, clientTestResults) { // Check integrations — if a result exists and failed, it's an error if (results.sonarr && results.sonarr.success === false) return true; if (results.radarr && results.radarr.success === false) return true; + if (results.lidarr && results.lidarr.success === false) return true; + if (results.readarr && results.readarr.success === false) return true; if (results.prowlarr && results.prowlarr.success === false) return true; return false; @@ -63,6 +65,8 @@ export function checkResultsForErrors(results, clientTestResults) { // Check integrations if (testData.sonarr && testData.sonarr.success === false) return true; if (testData.radarr && testData.radarr.success === false) return true; + if (testData.lidarr && testData.lidarr.success === false) return true; + if (testData.readarr && testData.readarr.success === false) return true; if (testData.prowlarr && testData.prowlarr.success === false) return true; return false; @@ -102,6 +106,16 @@ export function buildTestPayload(formData, unmaskPasswords = false, getUnmaskedC payload.radarr = configData.integrations.radarr; } + // Add Lidarr if enabled + if (configData.integrations?.lidarr?.enabled) { + payload.lidarr = configData.integrations.lidarr; + } + + // Add Readarr if enabled + if (configData.integrations?.readarr?.enabled) { + payload.readarr = configData.integrations.readarr; + } + // Add Prowlarr if enabled if (configData.integrations?.prowlarr?.enabled) { payload.prowlarr = configData.integrations.prowlarr;