diff --git a/backend/Clients/UsenetProviderConnectionAllocator.cs b/backend/Clients/UsenetProviderConnectionAllocator.cs new file mode 100644 index 00000000..068c3008 --- /dev/null +++ b/backend/Clients/UsenetProviderConnectionAllocator.cs @@ -0,0 +1,162 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NzbWebDAV.Config; + +namespace NzbWebDAV.Clients; + +public sealed class UsenetProviderConnectionAllocator +{ + private readonly UsenetProviderConfig[] _providers; + private readonly int[] _liveConnections; + private readonly object _sync = new(); + private int _nextIndex; + + public UsenetProviderConnectionAllocator(IEnumerable providers) + { + _providers = providers?.ToArray() ?? throw new ArgumentNullException(nameof(providers)); + if (_providers.Length == 0) + { + throw new ArgumentException("At least one usenet provider must be configured.", nameof(providers)); + } + + _liveConnections = new int[_providers.Length]; + } + + public int TotalConnections => _providers.Sum(provider => provider.Connections); + + public ValueTask CreateConnectionAsync(CancellationToken cancellationToken) + { + int providerIndex; + UsenetProviderConfig provider; + + lock (_sync) + { + providerIndex = ReserveProviderIndex(); + provider = _providers[providerIndex]; + _liveConnections[providerIndex]++; + } + + return CreateProviderConnectionAsync(providerIndex, provider, cancellationToken); + } + + private int ReserveProviderIndex() + { + for (var attempt = 0; attempt < _providers.Length; attempt++) + { + var index = (_nextIndex + attempt) % _providers.Length; + if (_liveConnections[index] < _providers[index].Connections) + { + _nextIndex = index + 1; + return index; + } + } + + throw new InvalidOperationException("No available usenet provider capacity."); + } + + private async ValueTask CreateProviderConnectionAsync( + int providerIndex, + UsenetProviderConfig provider, + CancellationToken cancellationToken) + { + try + { + var connection = await UsenetStreamingClient.CreateNewConnection( + provider.Host, + provider.Port, + provider.UseSsl, + provider.User, + provider.Pass, + cancellationToken); + + return new ProviderScopedNntpClient(connection, () => Release(providerIndex)); + } + catch + { + Release(providerIndex); + throw; + } + } + + private void Release(int providerIndex) + { + lock (_sync) + { + if (_liveConnections[providerIndex] > 0) + { + _liveConnections[providerIndex]--; + } + } + } + + private sealed class ProviderScopedNntpClient : INntpClient + { + private readonly INntpClient _inner; + private readonly Action _onDispose; + private int _disposed; + + public ProviderScopedNntpClient(INntpClient inner, Action onDispose) + { + _inner = inner; + _onDispose = onDispose; + } + + public Task ConnectAsync(string host, int port, bool useSsl, CancellationToken cancellationToken) + { + return _inner.ConnectAsync(host, port, useSsl, cancellationToken); + } + + public Task AuthenticateAsync(string user, string pass, CancellationToken cancellationToken) + { + return _inner.AuthenticateAsync(user, pass, cancellationToken); + } + + public Task StatAsync(string segmentId, CancellationToken cancellationToken) + { + return _inner.StatAsync(segmentId, cancellationToken); + } + + public Task GetSegmentStreamAsync(string segmentId, CancellationToken cancellationToken) + { + return _inner.GetSegmentStreamAsync(segmentId, cancellationToken); + } + + public Task GetSegmentYencHeaderAsync(string segmentId, CancellationToken cancellationToken) + { + return _inner.GetSegmentYencHeaderAsync(segmentId, cancellationToken); + } + + public Task GetFileSizeAsync(Usenet.Nzb.NzbFile file, CancellationToken cancellationToken) + { + return _inner.GetFileSizeAsync(file, cancellationToken); + } + + public Task DateAsync(CancellationToken cancellationToken) + { + return _inner.DateAsync(cancellationToken); + } + + public Task WaitForReady(CancellationToken cancellationToken) + { + return _inner.WaitForReady(cancellationToken); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) + { + return; + } + + try + { + _inner.Dispose(); + } + finally + { + _onDispose(); + } + } + } +} diff --git a/backend/Clients/UsenetStreamingClient.cs b/backend/Clients/UsenetStreamingClient.cs index eda26a06..f6131831 100644 --- a/backend/Clients/UsenetStreamingClient.cs +++ b/backend/Clients/UsenetStreamingClient.cs @@ -19,17 +19,8 @@ public UsenetStreamingClient(ConfigManager configManager, WebsocketManager webso // initialize private members _websocketManager = websocketManager; - // get connection settings from config-manager - var host = configManager.GetConfigValue("usenet.host") ?? string.Empty; - var port = int.Parse(configManager.GetConfigValue("usenet.port") ?? "119"); - var useSsl = bool.Parse(configManager.GetConfigValue("usenet.use-ssl") ?? "false"); - var user = configManager.GetConfigValue("usenet.user") ?? string.Empty; - var pass = configManager.GetConfigValue("usenet.pass") ?? string.Empty; - var connections = configManager.GetMaxConnections(); - // initialize the nntp-client - var createNewConnection = (CancellationToken ct) => CreateNewConnection(host, port, useSsl, user, pass, ct); - var connectionPool = CreateNewConnectionPool(connections, createNewConnection); + var connectionPool = BuildConnectionPool(configManager.GetUsenetProviders()); var multiConnectionClient = new MultiConnectionNntpClient(connectionPool); var cache = new MemoryCache(new MemoryCacheOptions() { SizeLimit = 8192 }); _client = new CachingNntpClient(multiConnectionClient, cache); @@ -43,17 +34,11 @@ public UsenetStreamingClient(ConfigManager configManager, WebsocketManager webso !configEventArgs.ChangedConfig.ContainsKey("usenet.use-ssl") && !configEventArgs.ChangedConfig.ContainsKey("usenet.user") && !configEventArgs.ChangedConfig.ContainsKey("usenet.pass") && - !configEventArgs.ChangedConfig.ContainsKey("usenet.connections")) return; - - // update the connection-pool according to the new config - var connectionCount = int.Parse(configEventArgs.NewConfig["usenet.connections"]); - var newHost = configEventArgs.NewConfig["usenet.host"]; - var newPort = int.Parse(configEventArgs.NewConfig["usenet.port"]); - var newUseSsl = bool.Parse(configEventArgs.NewConfig.GetValueOrDefault("usenet.use-ssl", "false")); - var newUser = configEventArgs.NewConfig["usenet.user"]; - var newPass = configEventArgs.NewConfig["usenet.pass"]; - var newConnectionPool = CreateNewConnectionPool(connectionCount, cancellationToken => - CreateNewConnection(newHost, newPort, newUseSsl, newUser, newPass, cancellationToken)); + !configEventArgs.ChangedConfig.ContainsKey("usenet.connections") && + !configEventArgs.ChangedConfig.ContainsKey("usenet.providers")) return; + + var providers = configManager.GetUsenetProviders(); + var newConnectionPool = BuildConnectionPool(providers); multiConnectionClient.UpdateConnectionPool(newConnectionPool); }; } @@ -123,6 +108,13 @@ private void OnConnectionPoolChanged(object? _, ConnectionPool.Conn _websocketManager.SendMessage(WebsocketTopic.UsenetConnections, message); } + private ConnectionPool BuildConnectionPool(IReadOnlyList providers) + { + var allocator = new UsenetProviderConnectionAllocator(providers); + var maxConnections = Math.Max(allocator.TotalConnections, 1); + return CreateNewConnectionPool(maxConnections, allocator.CreateConnectionAsync); + } + public static async ValueTask CreateNewConnection ( string host, diff --git a/backend/Config/ConfigManager.cs b/backend/Config/ConfigManager.cs index dc8b60e9..e5c41c04 100644 --- a/backend/Config/ConfigManager.cs +++ b/backend/Config/ConfigManager.cs @@ -1,4 +1,6 @@ -using Microsoft.EntityFrameworkCore; +using System.Linq; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; using NzbWebDAV.Database; using NzbWebDAV.Database.Models; using NzbWebDAV.Utils; @@ -44,7 +46,7 @@ public void UpdateValues(List configItems) OnConfigChanged?.Invoke(this, new ConfigEventArgs { ChangedConfig = configItems.ToDictionary(x => x.ConfigName, x => x.ConfigValue), - NewConfig = _config + NewConfig = new Dictionary(_config), }); } } @@ -69,12 +71,19 @@ public string GetApiCategories() ?? "audio,software,tv,movies"; } + public IReadOnlyList GetUsenetProviders() + { + lock (_config) + { + return ParseUsenetProviders(_config); + } + } + public int GetMaxConnections() { - return int.Parse( - StringUtil.EmptyToNull(GetConfigValue("usenet.connections")) - ?? "10" - ); + var providers = GetUsenetProviders(); + var maxConnections = providers.Sum(provider => Math.Max(provider.Connections, 0)); + return maxConnections > 0 ? maxConnections : 1; } public int GetConnectionsPerStream() @@ -140,4 +149,146 @@ public class ConfigEventArgs : EventArgs public Dictionary ChangedConfig { get; set; } = new(); public Dictionary NewConfig { get; set; } = new(); } -} \ No newline at end of file + + private static IReadOnlyList ParseUsenetProviders(IReadOnlyDictionary source) + { + var providers = new List(); + + if (source.TryGetValue("usenet.providers", out var rawProviders) && + !string.IsNullOrWhiteSpace(rawProviders)) + { + try + { + using var document = JsonDocument.Parse(rawProviders); + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (var element in document.RootElement.EnumerateArray()) + { + var provider = BuildProviderFromJsonElement(element); + if (provider != null) + { + providers.Add(provider); + } + } + } + } + catch (JsonException) + { + // ignore malformed JSON and fall back to legacy configuration keys + } + } + + if (providers.Count > 0) + { + return providers; + } + + return new[] + { + BuildLegacyProvider(source) + }; + } + + private static UsenetProviderConfig BuildLegacyProvider(IReadOnlyDictionary source) + { + var host = source.GetValueOrDefault("usenet.host") ?? string.Empty; + var port = ParseInt(source.GetValueOrDefault("usenet.port"), 119); + var useSsl = ParseBool(source.GetValueOrDefault("usenet.use-ssl")); + var user = source.GetValueOrDefault("usenet.user") ?? string.Empty; + var pass = source.GetValueOrDefault("usenet.pass") ?? string.Empty; + var connections = Math.Max(ParseInt(source.GetValueOrDefault("usenet.connections"), 10), 1); + var name = !string.IsNullOrWhiteSpace(host) ? "Primary" : "Provider 1"; + + return new UsenetProviderConfig(name, host, port, useSsl, user, pass, connections); + } + + private static UsenetProviderConfig? BuildProviderFromJsonElement(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + string GetString(string propertyName) + { + if (!element.TryGetProperty(propertyName, out var value)) + { + return string.Empty; + } + + return value.ValueKind switch + { + JsonValueKind.String => value.GetString() ?? string.Empty, + JsonValueKind.Number => value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => string.Empty + }; + } + + int ParseElementInt(string propertyName, int defaultValue) + { + if (!element.TryGetProperty(propertyName, out var value)) + { + return defaultValue; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) + { + return number; + } + + if (value.ValueKind == JsonValueKind.String && + int.TryParse(value.GetString(), out var parsed)) + { + return parsed; + } + + return defaultValue; + } + + bool ParseElementBool(string propertyName) + { + if (!element.TryGetProperty(propertyName, out var value)) + { + return false; + } + + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed, + _ => false + }; + } + + var name = GetString("name"); + var host = GetString("host"); + var port = ParseElementInt("port", 119); + var useSsl = ParseElementBool("useSsl"); + var user = GetString("user"); + var pass = GetString("pass"); + var connections = Math.Max(ParseElementInt("connections", 10), 1); + + return new UsenetProviderConfig( + string.IsNullOrWhiteSpace(name) ? "Provider" : name, + host, + port, + useSsl, + user, + pass, + connections + ); + } + + private static int ParseInt(string? value, int defaultValue) + { + return int.TryParse(value, out var parsed) ? parsed : defaultValue; + } + + private static bool ParseBool(string? value) + { + return bool.TryParse(value, out var parsed) && parsed; + } +} diff --git a/backend/Config/UsenetProviderConfig.cs b/backend/Config/UsenetProviderConfig.cs new file mode 100644 index 00000000..23dbf42e --- /dev/null +++ b/backend/Config/UsenetProviderConfig.cs @@ -0,0 +1,11 @@ +namespace NzbWebDAV.Config; + +public record UsenetProviderConfig( + string Name, + string Host, + int Port, + bool UseSsl, + string User, + string Pass, + int Connections +); diff --git a/backend/Database/Models/ConfigItem.cs b/backend/Database/Models/ConfigItem.cs index 15f76780..324fac1d 100644 --- a/backend/Database/Models/ConfigItem.cs +++ b/backend/Database/Models/ConfigItem.cs @@ -11,6 +11,7 @@ public class ConfigItem "usenet.port", "usenet.use-ssl", "usenet.connections", + "usenet.providers", "usenet.user", "usenet.pass", "webdav.user", diff --git a/frontend/app/routes/settings/route.tsx b/frontend/app/routes/settings/route.tsx index d55098fd..2dfd570f 100644 --- a/frontend/app/routes/settings/route.tsx +++ b/frontend/app/routes/settings/route.tsx @@ -18,6 +18,7 @@ const defaultConfig = { "usenet.port": "", "usenet.use-ssl": "false", "usenet.connections": "", + "usenet.providers": "[]", "usenet.connections-per-stream": "", "usenet.user": "", "usenet.pass": "", diff --git a/frontend/app/routes/settings/usenet/usenet.module.css b/frontend/app/routes/settings/usenet/usenet.module.css index feac1f2f..1ebdbcbf 100644 --- a/frontend/app/routes/settings/usenet/usenet.module.css +++ b/frontend/app/routes/settings/usenet/usenet.module.css @@ -1,5 +1,6 @@ + .container { - max-width:400px; + max-width: 540px; margin-bottom: 25px; } @@ -7,6 +8,41 @@ margin-bottom: 12px; } +.providersContainer { + display: flex; + flex-direction: column; + gap: 16px; +} + +.providerCard { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 16px; + background: rgba(255, 255, 255, 0.02); +} + +.providerHeader { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.removeProviderButton { + align-self: center; + margin-top: 30px; +} + +.addProviderButton { + margin-top: 12px; + margin-bottom: 12px; +} + +.connectionsSummary { + margin-bottom: 12px; + font-style: italic; +} + .justify-right{ display: flex; justify-content: end; diff --git a/frontend/app/routes/settings/usenet/usenet.tsx b/frontend/app/routes/settings/usenet/usenet.tsx index 1be9da49..2198c7dd 100644 --- a/frontend/app/routes/settings/usenet/usenet.tsx +++ b/frontend/app/routes/settings/usenet/usenet.tsx @@ -1,159 +1,437 @@ import { Button, Form } from "react-bootstrap"; -import styles from "./usenet.module.css" -import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import styles from "./usenet.module.css"; +import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction, +} from "react"; type UsenetSettingsProps = { - config: Record - setNewConfig: Dispatch>> - onReadyToSave: (isReadyToSave: boolean) => void + config: Record; + setNewConfig: Dispatch>>; + onReadyToSave: (isReadyToSave: boolean) => void; +}; + +type ProviderFormState = { + name: string; + host: string; + port: string; + useSsl: boolean; + user: string; + pass: string; + connections: string; +}; + +const blankProvider = (index: number): ProviderFormState => ({ + name: `Provider ${index + 1}`, + host: "", + port: "", + useSsl: true, + user: "", + pass: "", + connections: "", +}); + +const providerHash = (provider: ProviderFormState) => [ + provider.name, + provider.host, + provider.port, + provider.useSsl ? "true" : "false", + provider.user, + provider.pass, + provider.connections, +].join("|"); + +const parseProvidersFromConfig = (config: Record): ProviderFormState[] => { + const providers: ProviderFormState[] = []; + const rawProviders = config["usenet.providers"]; + if (rawProviders) { + try { + const parsed = JSON.parse(rawProviders); + if (Array.isArray(parsed)) { + parsed.forEach((value: any, index: number) => { + providers.push({ + name: typeof value?.name === "string" && value.name.trim() !== "" + ? value.name + : `Provider ${index + 1}`, + host: stringFromUnknown(value?.host), + port: stringFromUnknown(value?.port), + useSsl: parseBoolean(value?.useSsl), + user: stringFromUnknown(value?.user), + pass: stringFromUnknown(value?.pass), + connections: stringFromUnknown(value?.connections), + }); + }); + } + } catch { + /* ignore malformed provider JSON */ + } + } + + if (providers.length > 0) { + return providers; + } + + return [ + { + name: config["usenet.host"] ? "Primary" : "Provider 1", + host: config["usenet.host"] || "", + port: config["usenet.port"] || "", + useSsl: config["usenet.use-ssl"] === "true", + user: config["usenet.user"] || "", + pass: config["usenet.pass"] || "", + connections: config["usenet.connections"] || "", + }, + ]; +}; + +const stringFromUnknown = (value: unknown): string => { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number") { + return Number.isFinite(value) ? String(value) : ""; + } + return ""; +}; + +const parseBoolean = (value: unknown): boolean => { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + return value.trim().toLowerCase() === "true"; + } + return false; +}; + +const stringifyProviders = (providers: ProviderFormState[]): string => + JSON.stringify( + providers.map(provider => ({ + name: provider.name, + host: provider.host, + port: provider.port, + useSsl: provider.useSsl, + user: provider.user, + pass: provider.pass, + connections: provider.connections, + })), + ); + +const computeTotalConnections = (providers: ProviderFormState[]): number => + providers.reduce((sum, provider) => { + const connections = Number.parseInt(provider.connections, 10); + if (Number.isFinite(connections)) { + return sum + connections; + } + return sum; + }, 0); + +const validateProvider = (provider: ProviderFormState): string | null => + !provider.host + ? "`Host` is required" + : !provider.port + ? "`Port` is required" + : !isPositiveInteger(provider.port) + ? "`Port` is invalid" + : !provider.user + ? "`User` is required" + : !provider.pass + ? "`Pass` is required" + : !provider.connections + ? "`Max Connections` is required" + : !isPositiveInteger(provider.connections) + ? "`Max Connections` is invalid" + : null; + +const getConnectionsPerStreamError = ( + value: string, + totalConnections: number, +): string | null => { + if (!value) { + return "`Connections Per Stream` is required"; + } + if (!isPositiveInteger(value)) { + return "`Connections Per Stream` is invalid"; + } + const perStream = Number(value); + if (totalConnections > 0 && perStream > totalConnections) { + return "`Connections Per Stream` is invalid"; + } + return null; }; export function UsenetSettings({ config, setNewConfig, onReadyToSave }: UsenetSettingsProps) { - const [isFetching, setIsFetching] = useState(false); - const [isConnectionSuccessful, setIsConnectionSuccessful] = useState(false); - const [testedConfig, setTestedConfig] = useState({}); - const isChangedSinceLastTest = isUsenetSettingsUpdated(config, testedConfig); - - const TestButtonLabel = isFetching ? "Testing Connection..." - : !config["usenet.host"] ? "`Host` is required" - : !config["usenet.port"] ? "`Port` is required" - : !isPositiveInteger(config["usenet.port"]) ? "`Port` is invalid" - : !config["usenet.user"] ? "`User` is required" - : !config["usenet.pass"] ? "`Pass` is required" - : !config["usenet.connections"] ? "`Max Connections` is required" - : !config["usenet.connections-per-stream"] ? "`Connections Per Stream` is required" - : !isPositiveInteger(config["usenet.connections"]) ? "`Max Connections` is invalid" - : !config["usenet.connections-per-stream"] ? "`Connections Per Stream` is required" - : !isPositiveInteger(config["usenet.connections-per-stream"]) ? "`Connections Per Stream` is invalid" - : Number(config["usenet.connections-per-stream"]) > Number(config["usenet.connections"]) ? "`Connections Per Stream` is invalid" - : !isChangedSinceLastTest && isConnectionSuccessful ? "Connected ✅" - : !isChangedSinceLastTest && !isConnectionSuccessful ? "Test Connection ❌" - : "Test Connection"; - const testButtonVariant = isFetching ? "secondary" - : TestButtonLabel === "Connected ✅" ? "success" - : TestButtonLabel.includes("Test Connection") ? "primary" - : "danger"; - const IsTestButtonEnabled = TestButtonLabel == "Test Connection" - || TestButtonLabel == "Test Connection ❌"; - - const isReadyToSave = isConnectionSuccessful && !isChangedSinceLastTest; + const providers = useMemo(() => parseProvidersFromConfig(config), [config]); + const [testedProviders, setTestedProviders] = useState>({}); + const [testingIndex, setTestingIndex] = useState(null); + + const totalConnections = useMemo(() => computeTotalConnections(providers), [providers]); + const connectionsPerStream = config["usenet.connections-per-stream"] || ""; + const connectionsPerStreamError = useMemo( + () => getConnectionsPerStreamError(connectionsPerStream, totalConnections), + [connectionsPerStream, totalConnections], + ); + useEffect(() => { - onReadyToSave && onReadyToSave(isReadyToSave); - }, [isReadyToSave]) - - const onTestButtonClicked = useCallback(async () => { - setIsFetching(true); - const response = await fetch("/api/test-usenet-connection", { - method: "POST", - body: (() => { - const form = new FormData(); - form.append("host", config["usenet.host"]); - form.append("port", config["usenet.port"]); - form.append("use-ssl", config["usenet.use-ssl"] || "false"); - form.append("user", config["usenet.user"]); - form.append("pass", config["usenet.pass"]); - return form; - })() - }); - const isConnectionSuccessful = response.ok && ((await response.json())?.connected === true); - setIsFetching(false); - setTestedConfig(config); - setIsConnectionSuccessful(isConnectionSuccessful); - }, [config, setIsFetching, setIsConnectionSuccessful]); + const allProvidersValid = providers.every(provider => validateProvider(provider) === null); + const allProvidersTested = providers.every(provider => testedProviders[providerHash(provider)] === true); + const isReady = + providers.length > 0 && + allProvidersValid && + allProvidersTested && + !connectionsPerStreamError; + onReadyToSave(isReady); + }, [providers, testedProviders, connectionsPerStreamError, onReadyToSave]); + + const applyProviders = useCallback((nextProviders: ProviderFormState[]) => { + const providersToSave = nextProviders.length > 0 ? nextProviders : [blankProvider(0)]; + const serializedProviders = stringifyProviders(providersToSave); + const firstProvider = providersToSave[0]; + const total = computeTotalConnections(providersToSave); + + setNewConfig(prev => ({ + ...prev, + "usenet.providers": serializedProviders, + "usenet.host": firstProvider.host, + "usenet.port": firstProvider.port, + "usenet.use-ssl": firstProvider.useSsl ? "true" : "false", + "usenet.user": firstProvider.user, + "usenet.pass": firstProvider.pass, + "usenet.connections": total > 0 ? String(total) : "", + })); + }, [setNewConfig]); + + const updateProvider = useCallback((index: number, updates: Partial) => { + const nextProviders = providers.map((provider, providerIndex) => + providerIndex === index ? { ...provider, ...updates } : provider, + ); + applyProviders(nextProviders); + }, [providers, applyProviders]); + + const addProvider = useCallback(() => { + const nextProviders = [...providers, blankProvider(providers.length)]; + applyProviders(nextProviders); + }, [providers, applyProviders]); + + const removeProvider = useCallback((index: number) => { + const nextProviders = providers.filter((_, providerIndex) => providerIndex !== index); + applyProviders(nextProviders); + }, [providers, applyProviders]); + + const onTestProvider = useCallback(async (index: number) => { + const provider = providers[index]; + if (!provider) { + return; + } + + const validationMessage = validateProvider(provider); + if (validationMessage) { + return; + } + + const hash = providerHash(provider); + setTestingIndex(index); + try { + const form = new FormData(); + form.append("host", provider.host); + form.append("port", provider.port); + form.append("use-ssl", provider.useSsl ? "true" : "false"); + form.append("user", provider.user); + form.append("pass", provider.pass); + + const response = await fetch("/api/test-usenet-connection", { + method: "POST", + body: form, + }); + const success = response.ok && ((await response.json())?.connected === true); + setTestedProviders(prev => ({ ...prev, [hash]: success })); + } finally { + setTestingIndex(null); + } + }, [providers]); return (
+
+ {providers.map((provider, index) => { + const validationMessage = validateProvider(provider); + const hash = providerHash(provider); + const testStatus = testedProviders[hash]; + const isTesting = testingIndex === index; - - Host - setNewConfig({ ...config, "usenet.host": e.target.value })} /> - + let testButtonLabel: string; + if (isTesting) { + testButtonLabel = "Testing Connection..."; + } else if (validationMessage) { + testButtonLabel = validationMessage; + } else if (testStatus === true) { + testButtonLabel = "Connected ✅"; + } else if (testStatus === false) { + testButtonLabel = "Test Connection ❌"; + } else { + testButtonLabel = "Test Connection"; + } - - Port - setNewConfig({ ...config, "usenet.port": e.target.value })} /> - + const testButtonVariant = isTesting + ? "secondary" + : testStatus === true + ? "success" + : testStatus === false + ? "danger" + : "primary"; -
- setNewConfig({ ...config, "usenet.use-ssl": "" + e.target.checked })} /> -
+ const isTestEnabled = !validationMessage && !isTesting; -
+ return ( +
+
+ + Display Name + updateProvider(index, { name: event.target.value })} + /> + + {providers.length > 1 && ( + + )} +
- - User - setNewConfig({ ...config, "usenet.user": e.target.value })} /> - + + Host + updateProvider(index, { host: event.target.value })} + /> + - - Pass - setNewConfig({ ...config, "usenet.pass": e.target.value })} /> - + + Port + updateProvider(index, { port: event.target.value })} + /> + - - Max Connections - setNewConfig({ ...config, "usenet.connections": e.target.value })} /> - +
+ updateProvider(index, { useSsl: Boolean(event.target.checked) })} + /> +
+ + + User + updateProvider(index, { user: event.target.value })} + /> + + + + Pass + updateProvider(index, { pass: event.target.value })} + /> + + + + Max Connections + updateProvider(index, { connections: event.target.value })} + /> + + +
+ +
+
+ ); + })} +
+ + + +
+ Total Max Connections: {totalConnections || 0} +
Connections Per Stream setNewConfig({ ...config, "usenet.connections-per-stream": e.target.value })} /> + value={connectionsPerStream} + isInvalid={Boolean(connectionsPerStreamError)} + onChange={event => setNewConfig(prev => ({ + ...prev, + "usenet.connections-per-stream": event.target.value, + }))} + /> + {connectionsPerStreamError && ( + + {connectionsPerStreamError} + + )} - -
- -
); } export function isUsenetSettingsUpdated(config: Record, newConfig: Record) { - return config["usenet.host"] !== newConfig["usenet.host"] + return config["usenet.providers"] !== newConfig["usenet.providers"] + || config["usenet.host"] !== newConfig["usenet.host"] || config["usenet.port"] !== newConfig["usenet.port"] || config["usenet.use-ssl"] !== newConfig["usenet.use-ssl"] || config["usenet.user"] !== newConfig["usenet.user"] || config["usenet.pass"] !== newConfig["usenet.pass"] || config["usenet.connections"] !== newConfig["usenet.connections"] - || config["usenet.connections-per-stream"] !== newConfig["usenet.connections-per-stream"] + || config["usenet.connections-per-stream"] !== newConfig["usenet.connections-per-stream"]; } export function isPositiveInteger(value: string) { const num = Number(value); return Number.isInteger(num) && num > 0 && value.trim() === num.toString(); -} \ No newline at end of file +}