Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
2 changes: 1 addition & 1 deletion backend/Clients/RadarrSonarr/ArrClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrApiInfoResponse> GetApiInfo() =>
GetRoot<ArrApiInfoResponse>($"/api");
Expand Down
99 changes: 99 additions & 0 deletions backend/Clients/RadarrSonarr/LidarrClient.cs
Original file line number Diff line number Diff line change
@@ -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<string, int> ArtistPathToArtistIdCache = new();
private static readonly Dictionary<string, int> TrackFilePathToTrackFileIdCache = new();

public Task<List<LidarrArtist>> GetAllArtists() =>
Get<List<LidarrArtist>>($"/artist");

public Task<LidarrArtist> GetArtist(int artistId) =>
Get<LidarrArtist>($"/artist/{artistId}");

public Task<List<LidarrTrackFile>> GetAllTrackFiles(int artistId) =>
Get<List<LidarrTrackFile>>($"/trackfile?artistId={artistId}");

public Task<LidarrTrackFile> GetTrackFile(int trackFileId) =>
Get<LidarrTrackFile>($"/trackfile/{trackFileId}");

public Task<HttpStatusCode> DeleteTrackFile(int trackFileId) =>
Delete($"/trackfile/{trackFileId}");

public Task SearchArtistAsync(int artistId) =>
CommandAsync(new { name = "ArtistSearch", artistId });

public override async Task<bool> 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<int?> 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;
}
}
15 changes: 15 additions & 0 deletions backend/Clients/RadarrSonarr/LidarrModels/LidarrArtist.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
7 changes: 7 additions & 0 deletions backend/Clients/RadarrSonarr/LidarrModels/LidarrQueue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using NzbWebDAV.Clients.RadarrSonarr.BaseModels;

namespace NzbWebDAV.Clients.RadarrSonarr.LidarrModels;

public class LidarrQueue : ArrQueue<LidarrQueueRecord>
{
}
13 changes: 13 additions & 0 deletions backend/Clients/RadarrSonarr/LidarrModels/LidarrQueueRecord.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
15 changes: 15 additions & 0 deletions backend/Clients/RadarrSonarr/LidarrModels/LidarrTrackFile.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
5 changes: 4 additions & 1 deletion backend/Config/ArrConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ public class ArrConfig
{
public List<ConnectionDetails> RadarrInstances { get; set; } = [];
public List<ConnectionDetails> SonarrInstances { get; set; } = [];
public List<ConnectionDetails> LidarrInstances { get; set; } = [];
public List<QueueRule> QueueRules { get; set; } = [];

// ReSharper disable once InvokeAsExtensionMethod
public IEnumerable<ArrClient> 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
{
Expand Down
17 changes: 15 additions & 2 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 62 additions & 3 deletions frontend/app/routes/settings/arrs/arrs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface QueueRule {
interface ArrConfig {
RadarrInstances: ConnectionDetails[];
SonarrInstances: ConnectionDetails[];
LidarrInstances: ConnectionDetails[];
QueueRules: QueueRule[];
}

Expand Down Expand Up @@ -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 || [])
Expand Down Expand Up @@ -218,12 +247,35 @@ export function ArrsSettings({ config, setNewConfig }: ArrsSettingsProps) {
)}
</div>
<hr />
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div>Lidarr Instances</div>
<Button variant="primary" size="sm" onClick={addLidarrInstance}>
Add
</Button>
</div>
{arrConfig.LidarrInstances.length === 0 ? (
<p className={styles.alertMessage}>No Lidarr instances configured. Click on the "Add" button to get started.</p>
) : (
arrConfig.LidarrInstances.map((instance: any, index: number) =>
<InstanceForm
key={index}
instance={instance}
index={index}
type="lidarr"
onUpdate={updateLidarrInstance}
onRemove={removeLidarrInstance}
/>
)
)}
</div>
<hr />
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div>Automatic Queue Management</div>
</div>
<p className={styles.alertMessage}>
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.
</p>
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -308,7 +360,7 @@ function InstanceForm({ instance, index, type, onUpdate, onRemove }: InstanceFor
<InputGroup className={styles.input}>
<Form.Control
type="text"
placeholder={type === "radarr" ? "http://localhost:7878" : "http://localhost:8989"}
placeholder={type === "radarr" ? "http://localhost:7878" : type === "sonarr" ? "http://localhost:8989" : "http://localhost:8686"}
value={instance.Host}
onChange={e => onUpdate(index, 'Host', e.target.value)} />
{instance.Host.trim() && instance.ApiKey.trim() && (
Expand Down Expand Up @@ -369,6 +421,13 @@ export function isArrsSettingsValid(newConfig: Record<string, string>) {
}
}

// Validate all Lidarr instances
for (const instance of arrConfig.LidarrInstances || []) {
if (!isValidHost(instance.Host) || !isValidApiKey(instance.ApiKey)) {
return false;
}
}

return true;
} catch {
return false;
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/routes/settings/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions frontend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}`);
});