From 36b3acd2be116362cb59da44d2922ee3797dd612 Mon Sep 17 00:00:00 2001 From: korabcenaj Date: Tue, 5 May 2026 15:16:30 +0200 Subject: [PATCH 1/2] feat: add LISTEN_ADDRESS env var to configure bind interface Allows users to restrict which network interface nzbdav binds to via the LISTEN_ADDRESS environment variable. - Defaults to 0.0.0.0 (all interfaces), preserving existing behaviour - Sets ASPNETCORE_URLS in entrypoint.sh so Kestrel honours the address - Passes LISTEN_ADDRESS to the Node.js frontend server so both processes bind to the same interface - Derives BACKEND_URL from LISTEN_ADDRESS when it is a specific non-loopback/non-wildcard IP - Documents the variable with ENV LISTEN_ADDRESS=0.0.0.0 in Dockerfile and exposes port 8080 alongside the existing 3000 Fixes #408 --- Dockerfile | 6 +++++- entrypoint.sh | 17 +++++++++++++++-- frontend/server.ts | 5 +++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 98a47eb9..61c3a30b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,10 +48,14 @@ COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh # Set env variables -EXPOSE 3000 +# Port 3000: frontend Port 8080: backend (WebDAV / API) +EXPOSE 3000 8080 ARG NZBDAV_VERSION ENV NZBDAV_VERSION=${NZBDAV_VERSION} ENV NODE_ENV=production ENV LOG_LEVEL=warning +# LISTEN_ADDRESS controls the network interface both the frontend and backend bind to. +# Default is 0.0.0.0 (all interfaces). Set to a specific IP to restrict binding. +ENV LISTEN_ADDRESS=0.0.0.0 CMD ["/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh index c8ee1066..fb91deda 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -64,9 +64,22 @@ else USER_NAME=appuser fi -# Set environment variables +# Configure the listen address. +# Defaults to 0.0.0.0 (all interfaces), preserving the existing behaviour. +# Set LISTEN_ADDRESS to a specific IP (e.g. 192.168.1.10) to restrict which +# network interface nzbdav binds to. +LISTEN_ADDRESS=${LISTEN_ADDRESS:-0.0.0.0} +export ASPNETCORE_URLS="http://${LISTEN_ADDRESS}:8080" + +# BACKEND_URL is used internally by the frontend to proxy requests to the backend. +# When LISTEN_ADDRESS is a wildcard/loopback address localhost is always reachable. +# When LISTEN_ADDRESS is a specific non-loopback IP, derive BACKEND_URL from it. if [ -z "${BACKEND_URL}" ]; then - export BACKEND_URL="http://localhost:8080" + if [ "$LISTEN_ADDRESS" = "0.0.0.0" ] || [ "$LISTEN_ADDRESS" = "127.0.0.1" ] || [ "$LISTEN_ADDRESS" = "localhost" ] || [ "$LISTEN_ADDRESS" = "::" ] || [ "$LISTEN_ADDRESS" = "::1" ]; then + export BACKEND_URL="http://localhost:8080" + else + export BACKEND_URL="http://${LISTEN_ADDRESS}:8080" + fi fi if [ -z "${FRONTEND_BACKEND_API_KEY}" ]; then diff --git a/frontend/server.ts b/frontend/server.ts index 2c66d99c..d41d186f 100644 --- a/frontend/server.ts +++ b/frontend/server.ts @@ -8,6 +8,7 @@ import { WebSocketServer } from "ws"; const BUILD_PATH = "../build/server/index.js"; const DEVELOPMENT = process.env.NODE_ENV === "development"; const PORT = Number.parseInt(process.env.PORT || "3000"); +const HOST = process.env.LISTEN_ADDRESS || "0.0.0.0"; // Initialize the express app const app = express(); @@ -90,6 +91,6 @@ const server = http.createServer(app); setWebsocketServer(new WebSocketServer({ server })); // Begin listening for connections -server.listen(PORT, () => { - console.log(`Server is running on http://localhost:${PORT}`); +server.listen(PORT, HOST, () => { + console.log(`Server is running on http://${HOST}:${PORT}`); }); From 8ba28c61e5d1dcaacc9371c7d78d5a3c5d16a16a Mon Sep 17 00:00:00 2001 From: korabcenaj Date: Tue, 5 May 2026 15:30:35 +0200 Subject: [PATCH 2/2] feat: add Lidarr support (#284) Add Lidarr as a first-class *arr integration alongside Radarr and Sonarr. Backend: - Make ArrClient.BasePath a protected virtual property so subclasses can override it (Lidarr uses /api/v1 instead of /api/v3) - Add LidarrInstances list to ArrConfig and include LidarrClient instances in GetArrClients() / GetInstanceCount() - New LidarrClient: overrides BasePath to /api/v1, implements RemoveAndSearch using artist-path cache + track-file lookup, same caching strategy as SonarrClient - New models: LidarrArtist, LidarrTrackFile, LidarrQueue, LidarrQueueRecord Frontend: - Add LidarrInstances field to ArrConfig interface - Add Add/Remove/Update callbacks for Lidarr instances - Add Lidarr Instances section to the settings UI (between Sonarr and Automatic Queue Management) - Default placeholder port 8686 for Lidarr host field - Include LidarrInstances in isArrsSettingsValid() validation - Update queue management description to mention Lidarr - Update default arr.instances config key to include LidarrInstances ArrMonitoringService automatically monitors Lidarr queues because it calls GetArrClients(), which now returns LidarrClient instances too. Closes #284 --- backend/Clients/RadarrSonarr/ArrClient.cs | 2 +- backend/Clients/RadarrSonarr/LidarrClient.cs | 99 +++++++++++++++++++ .../RadarrSonarr/LidarrModels/LidarrArtist.cs | 15 +++ .../RadarrSonarr/LidarrModels/LidarrQueue.cs | 7 ++ .../LidarrModels/LidarrQueueRecord.cs | 13 +++ .../LidarrModels/LidarrTrackFile.cs | 15 +++ backend/Config/ArrConfig.cs | 5 +- frontend/app/routes/settings/arrs/arrs.tsx | 65 +++++++++++- frontend/app/routes/settings/route.tsx | 2 +- 9 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 backend/Clients/RadarrSonarr/LidarrClient.cs create mode 100644 backend/Clients/RadarrSonarr/LidarrModels/LidarrArtist.cs create mode 100644 backend/Clients/RadarrSonarr/LidarrModels/LidarrQueue.cs create mode 100644 backend/Clients/RadarrSonarr/LidarrModels/LidarrQueueRecord.cs create mode 100644 backend/Clients/RadarrSonarr/LidarrModels/LidarrTrackFile.cs diff --git a/backend/Clients/RadarrSonarr/ArrClient.cs b/backend/Clients/RadarrSonarr/ArrClient.cs index ca36efc3..550cf798 100644 --- a/backend/Clients/RadarrSonarr/ArrClient.cs +++ b/backend/Clients/RadarrSonarr/ArrClient.cs @@ -12,7 +12,7 @@ public class ArrClient(string host, string apiKey) public string Host { get; } = host; private string ApiKey { get; } = apiKey; - private const string BasePath = "/api/v3"; + protected virtual string BasePath => "/api/v3"; public Task GetApiInfo() => GetRoot($"/api"); diff --git a/backend/Clients/RadarrSonarr/LidarrClient.cs b/backend/Clients/RadarrSonarr/LidarrClient.cs new file mode 100644 index 00000000..42e7d363 --- /dev/null +++ b/backend/Clients/RadarrSonarr/LidarrClient.cs @@ -0,0 +1,99 @@ +using System.Net; +using NzbWebDAV.Clients.RadarrSonarr.LidarrModels; +using NzbWebDAV.Utils; + +namespace NzbWebDAV.Clients.RadarrSonarr; + +public class LidarrClient(string host, string apiKey) : ArrClient(host, apiKey) +{ + protected override string BasePath => "/api/v1"; + + private static readonly Dictionary ArtistPathToArtistIdCache = new(); + private static readonly Dictionary TrackFilePathToTrackFileIdCache = new(); + + public Task> GetAllArtists() => + Get>($"/artist"); + + public Task GetArtist(int artistId) => + Get($"/artist/{artistId}"); + + public Task> GetAllTrackFiles(int artistId) => + Get>($"/trackfile?artistId={artistId}"); + + public Task GetTrackFile(int trackFileId) => + Get($"/trackfile/{trackFileId}"); + + public Task DeleteTrackFile(int trackFileId) => + Delete($"/trackfile/{trackFileId}"); + + public Task SearchArtistAsync(int artistId) => + CommandAsync(new { name = "ArtistSearch", artistId }); + + public override async Task RemoveAndSearch(string symlinkOrStrmPath) + { + var mediaIds = await GetMediaIds(symlinkOrStrmPath); + if (mediaIds == null) return false; + + if (await DeleteTrackFile(mediaIds.Value.trackFileId) != HttpStatusCode.OK) + throw new Exception($"Failed to delete track file `{symlinkOrStrmPath}` from Lidarr instance `{Host}`."); + + await SearchArtistAsync(mediaIds.Value.artistId); + return true; + } + + private async Task<(int trackFileId, int artistId)?> GetMediaIds(string symlinkOrStrmPath) + { + // if track-file-id is cached, verify and return it + if (TrackFilePathToTrackFileIdCache.TryGetValue(symlinkOrStrmPath, out var cachedTrackFileId)) + { + var trackFile = await GetTrackFile(cachedTrackFileId); + if (trackFile.Path == symlinkOrStrmPath) + return (cachedTrackFileId, trackFile.ArtistId); + } + + // find the artist whose root path is a prefix of the given file path + var artistId = await GetArtistId(symlinkOrStrmPath); + if (artistId == null) return null; + + // scan all track files for that artist and populate the cache + int? result = null; + foreach (var trackFile in await GetAllTrackFiles(artistId.Value)) + { + if (trackFile.Path != null) + TrackFilePathToTrackFileIdCache[trackFile.Path] = trackFile.Id; + if (trackFile.Path == symlinkOrStrmPath) + result = trackFile.Id; + } + + return result == null ? null : (result.Value, artistId.Value); + } + + private async Task GetArtistId(string symlinkOrStrmPath) + { + // check cache first using all parent directories + var cachedArtistId = PathUtil.GetAllParentDirectories(symlinkOrStrmPath) + .Where(p => ArtistPathToArtistIdCache.ContainsKey(p)) + .Select(p => ArtistPathToArtistIdCache[p]) + .Select(id => (int?)id) + .FirstOrDefault(); + + if (cachedArtistId != null) + { + var artist = await GetArtist(cachedArtistId.Value); + if (artist.Path != null && symlinkOrStrmPath.StartsWith(artist.Path)) + return cachedArtistId; + } + + // refresh all artists and repopulate cache + int? result = null; + foreach (var artist in await GetAllArtists()) + { + if (artist.Path != null) + ArtistPathToArtistIdCache[artist.Path] = artist.Id; + if (artist.Path != null && symlinkOrStrmPath.StartsWith(artist.Path)) + result = artist.Id; + } + + return result; + } +} diff --git a/backend/Clients/RadarrSonarr/LidarrModels/LidarrArtist.cs b/backend/Clients/RadarrSonarr/LidarrModels/LidarrArtist.cs new file mode 100644 index 00000000..3f73d88b --- /dev/null +++ b/backend/Clients/RadarrSonarr/LidarrModels/LidarrArtist.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace NzbWebDAV.Clients.RadarrSonarr.LidarrModels; + +public class LidarrArtist +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("artistName")] + public string? ArtistName { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } +} diff --git a/backend/Clients/RadarrSonarr/LidarrModels/LidarrQueue.cs b/backend/Clients/RadarrSonarr/LidarrModels/LidarrQueue.cs new file mode 100644 index 00000000..e0fea34a --- /dev/null +++ b/backend/Clients/RadarrSonarr/LidarrModels/LidarrQueue.cs @@ -0,0 +1,7 @@ +using NzbWebDAV.Clients.RadarrSonarr.BaseModels; + +namespace NzbWebDAV.Clients.RadarrSonarr.LidarrModels; + +public class LidarrQueue : ArrQueue +{ +} diff --git a/backend/Clients/RadarrSonarr/LidarrModels/LidarrQueueRecord.cs b/backend/Clients/RadarrSonarr/LidarrModels/LidarrQueueRecord.cs new file mode 100644 index 00000000..ccf551d5 --- /dev/null +++ b/backend/Clients/RadarrSonarr/LidarrModels/LidarrQueueRecord.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using NzbWebDAV.Clients.RadarrSonarr.BaseModels; + +namespace NzbWebDAV.Clients.RadarrSonarr.LidarrModels; + +public class LidarrQueueRecord : ArrQueueRecord +{ + [JsonPropertyName("artistId")] + public int ArtistId { get; set; } + + [JsonPropertyName("albumId")] + public int AlbumId { get; set; } +} diff --git a/backend/Clients/RadarrSonarr/LidarrModels/LidarrTrackFile.cs b/backend/Clients/RadarrSonarr/LidarrModels/LidarrTrackFile.cs new file mode 100644 index 00000000..e7dc5251 --- /dev/null +++ b/backend/Clients/RadarrSonarr/LidarrModels/LidarrTrackFile.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace NzbWebDAV.Clients.RadarrSonarr.LidarrModels; + +public class LidarrTrackFile +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("artistId")] + public int ArtistId { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } +} diff --git a/backend/Config/ArrConfig.cs b/backend/Config/ArrConfig.cs index c4ef03b1..c9e36aa6 100644 --- a/backend/Config/ArrConfig.cs +++ b/backend/Config/ArrConfig.cs @@ -6,16 +6,19 @@ public class ArrConfig { public List RadarrInstances { get; set; } = []; public List SonarrInstances { get; set; } = []; + public List LidarrInstances { get; set; } = []; public List QueueRules { get; set; } = []; // ReSharper disable once InvokeAsExtensionMethod public IEnumerable GetArrClients() => Enumerable.Concat( RadarrInstances.Select(ArrClient (x) => new RadarrClient(x.Host, x.ApiKey)), SonarrInstances.Select(ArrClient (x) => new SonarrClient(x.Host, x.ApiKey)) + ).Concat( + LidarrInstances.Select(ArrClient (x) => new LidarrClient(x.Host, x.ApiKey)) ); public int GetInstanceCount() => - RadarrInstances.Count + SonarrInstances.Count; + RadarrInstances.Count + SonarrInstances.Count + LidarrInstances.Count; public class ConnectionDetails { diff --git a/frontend/app/routes/settings/arrs/arrs.tsx b/frontend/app/routes/settings/arrs/arrs.tsx index 5a132c91..015c9ec9 100644 --- a/frontend/app/routes/settings/arrs/arrs.tsx +++ b/frontend/app/routes/settings/arrs/arrs.tsx @@ -20,6 +20,7 @@ interface QueueRule { interface ArrConfig { RadarrInstances: ConnectionDetails[]; SonarrInstances: ConnectionDetails[]; + LidarrInstances: ConnectionDetails[]; QueueRules: QueueRule[]; } @@ -149,6 +150,34 @@ export function ArrsSettings({ config, setNewConfig }: ArrsSettingsProps) { }); }, [arrConfig, updateConfig]); + const addLidarrInstance = useCallback(() => { + updateConfig({ + ...arrConfig, + LidarrInstances: [ + ...arrConfig.LidarrInstances, + { Host: "", ApiKey: "" } + ] + }); + }, [arrConfig, updateConfig]); + + const removeLidarrInstance = useCallback((index: number) => { + updateConfig({ + ...arrConfig, + LidarrInstances: arrConfig.LidarrInstances + .filter((_: any, i: number) => i !== index) + }); + }, [arrConfig, updateConfig]); + + const updateLidarrInstance = useCallback((index: number, field: keyof ConnectionDetails, value: string) => { + updateConfig({ + ...arrConfig, + LidarrInstances: arrConfig.LidarrInstances + .map((instance: any, i: number) => + i === index ? { ...instance, [field]: value } : instance + ) + }); + }, [arrConfig, updateConfig]); + const updateQueueAction = useCallback((searchTerm: string, action: number) => { // update the queue rule if it already exists var newQueueRules = (arrConfig.QueueRules || []) @@ -218,12 +247,35 @@ export function ArrsSettings({ config, setNewConfig }: ArrsSettingsProps) { )}
+
+
+
Lidarr Instances
+ +
+ {arrConfig.LidarrInstances.length === 0 ? ( +

No Lidarr instances configured. Click on the "Add" button to get started.

+ ) : ( + arrConfig.LidarrInstances.map((instance: any, index: number) => + + ) + )} +
+
Automatic Queue Management

- Configure what to do for items stuck in Radarr / Sonarr queues. + Configure what to do for items stuck in Radarr / Sonarr / Lidarr queues. Different actions can be configured for different status messages. Only `usenet` queue items will be acted upon.

@@ -252,7 +304,7 @@ export function ArrsSettings({ config, setNewConfig }: ArrsSettingsProps) { interface InstanceFormProps { instance: ConnectionDetails; index: number; - type: 'radarr' | 'sonarr'; + type: 'radarr' | 'sonarr' | 'lidarr'; onUpdate: (index: number, field: keyof ConnectionDetails, value: string) => void; onRemove: (index: number) => void; } @@ -308,7 +360,7 @@ function InstanceForm({ instance, index, type, onUpdate, onRemove }: InstanceFor onUpdate(index, 'Host', e.target.value)} /> {instance.Host.trim() && instance.ApiKey.trim() && ( @@ -369,6 +421,13 @@ export function isArrsSettingsValid(newConfig: Record) { } } + // Validate all Lidarr instances + for (const instance of arrConfig.LidarrInstances || []) { + if (!isValidHost(instance.Host) || !isValidApiKey(instance.ApiKey)) { + return false; + } + } + return true; } catch { return false; diff --git a/frontend/app/routes/settings/route.tsx b/frontend/app/routes/settings/route.tsx index 0a8f4274..215b5be1 100644 --- a/frontend/app/routes/settings/route.tsx +++ b/frontend/app/routes/settings/route.tsx @@ -41,7 +41,7 @@ const defaultConfig = { "rclone.pass": "", "rclone.mount-dir": "", "media.library-dir": "", - "arr.instances": "{\"RadarrInstances\":[],\"SonarrInstances\":[],\"QueueRules\":[]}", + "arr.instances": "{\"RadarrInstances\":[],\"SonarrInstances\":[],\"LidarrInstances\":[],\"QueueRules\":[]}", "repair.enable": "false", "db.is-startup-vacuum-enabled": "false", "maintenance.remove-orphaned-schedule-enabled": "false",