diff --git a/src/OpenIPC.Viewer.App/Services/DiscoveryDialogFactory.cs b/src/OpenIPC.Viewer.App/Services/DiscoveryDialogFactory.cs index d1586c4..939f00f 100644 --- a/src/OpenIPC.Viewer.App/Services/DiscoveryDialogFactory.cs +++ b/src/OpenIPC.Viewer.App/Services/DiscoveryDialogFactory.cs @@ -1,6 +1,8 @@ +using System.Collections.Generic; using Microsoft.Extensions.Logging; using OpenIPC.Viewer.App.ViewModels.Dialogs; using OpenIPC.Viewer.Core.Discovery; +using OpenIPC.Viewer.Core.Majestic; using OpenIPC.Viewer.Core.Onvif; namespace OpenIPC.Viewer.App.Services; @@ -9,18 +11,27 @@ public sealed class DiscoveryDialogFactory { private readonly IDiscoveryAggregator _aggregator; private readonly OnvifProbeService _probe; + private readonly IMajesticClient _majestic; + private readonly DiscoverySessionCache _cache; private readonly ILoggerFactory _loggerFactory; public DiscoveryDialogFactory( IDiscoveryAggregator aggregator, OnvifProbeService probe, + IMajesticClient majestic, + DiscoverySessionCache cache, ILoggerFactory loggerFactory) { _aggregator = aggregator; _probe = probe; + _majestic = majestic; + _cache = cache; _loggerFactory = loggerFactory; } - public DiscoveryDialogViewModel Create() => - new(_aggregator, _probe, _loggerFactory.CreateLogger()); + // knownHosts: hosts of cameras already in the library, so rows can carry + // an "already added" badge during the multi-add loop. + public DiscoveryDialogViewModel Create(IReadOnlySet knownHosts) => + new(_aggregator, _probe, _majestic, _cache, knownHosts, + _loggerFactory.CreateLogger()); } diff --git a/src/OpenIPC.Viewer.App/Services/DiscoverySessionCache.cs b/src/OpenIPC.Viewer.App/Services/DiscoverySessionCache.cs new file mode 100644 index 0000000..0ae86ca --- /dev/null +++ b/src/OpenIPC.Viewer.App/Services/DiscoverySessionCache.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using OpenIPC.Viewer.Core.Discovery; + +namespace OpenIPC.Viewer.App.Services; + +// Session-scoped memory for the discovery dialog, so the user can add several +// cameras from ONE scan: results, the typed credentials and the deep-scan flag +// all survive closing and reopening the dialog. In-memory only on purpose — +// the credentials must never be persisted to disk from here (they end up in +// the secrets store per-camera once a camera is saved). +public sealed class DiscoverySessionCache +{ + private readonly object _sync = new(); + private readonly Dictionary _devices = + new(StringComparer.OrdinalIgnoreCase); + + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + public bool DeepScan { get; set; } + + // "Use these credentials for all cameras" — on (default) carries the typed + // login into the next add; off makes every camera start with blank fields + // (mixed-credential parks). + public bool ReuseCredentials { get; set; } = true; + + public IReadOnlyList Snapshot() + { + lock (_sync) + return new List(_devices.Values); + } + + public void Put(DiscoveredDevice device) + { + lock (_sync) + _devices[device.Host] = device; + } + + public void Clear() + { + lock (_sync) + _devices.Clear(); + } +} diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 12083d1..19ff488 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -396,6 +396,8 @@ private static LangCode DetectSystem() ["Discovery.Status.ManualAdd"] = "No ONVIF — review the stream URL in the editor.", ["Discovery.DeepScan"] = "Deep scan (slower)", ["Discovery.UnknownModel"] = "(unknown model)", + ["Discovery.AlreadyAdded"] = "added", + ["Discovery.ReuseCreds"] = "Use these credentials for all cameras", ["Discovery.Confidence.Low"] = "low", ["Discovery.Confidence.Medium"] = "medium", ["Discovery.Confidence.High"] = "high", @@ -867,6 +869,8 @@ private static LangCode DetectSystem() ["Discovery.Status.ManualAdd"] = "Без ONVIF — проверьте URL потока в редакторе.", ["Discovery.DeepScan"] = "Глубокое сканирование (медленнее)", ["Discovery.UnknownModel"] = "(неизвестная модель)", + ["Discovery.AlreadyAdded"] = "добавлена", + ["Discovery.ReuseCreds"] = "Одни учётные данные для всех камер", ["Discovery.Confidence.Low"] = "низкая", ["Discovery.Confidence.Medium"] = "средняя", ["Discovery.Confidence.High"] = "высокая", diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs index 71f93ba..6ff371a 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs @@ -322,39 +322,53 @@ private async Task AddCameraAsync() [RelayCommand] private async Task DiscoverCameraAsync() { - var discoveryVm = _discoveryFactory.Create(); - var found = await _dialogs.ShowDiscoveryDialogAsync(discoveryVm).ConfigureAwait(true); - if (found is null) - return; - - // Pre-fill the editor from the probe result so the user sees / can tweak - // everything before saving (RTSP URI especially — phase-04 risks §"ONVIF - // returns wrong RTSP URI behind NAT" applies). - var editor = _editorFactory.CreateForNew(); - editor.Name = found.Device.Model ?? found.Device.Name ?? found.Device.Host; - editor.Host = found.Device.Host; - editor.OnvifPortText = (found.Device.OnvifServiceUri?.Port ?? 80).ToString(System.Globalization.CultureInfo.InvariantCulture); - editor.RtspMainText = found.RtspMainUri.ToString(); - editor.Username = found.Credentials?.Username ?? ""; - editor.Password = found.Credentials?.Password ?? ""; - - var result = await _dialogs.ShowCameraEditorAsync(editor).ConfigureAwait(true); - if (result?.NewRequest is not { } req) - return; - - try + // Multi-add loop: after each camera is saved (or its editor cancelled) + // the discovery dialog reopens with the SAME scan results and creds + // (DiscoverySessionCache), so several cameras go in from one scan. + // Only cancelling the discovery dialog itself exits. + var knownHosts = new System.Collections.Generic.HashSet( + System.Linq.Enumerable.Select(_allCameras, c => c.Host), + StringComparer.OrdinalIgnoreCase); + + while (true) { - var id = await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true); - // Persist HasPtz / ProfileToken / manufacturer info from the probe so - // SingleCameraPage knows whether to show the PTZ joystick (Phase 4c). - // Non-ONVIF devices (sweep/mDNS) have no probe — nothing to persist. - if (found.Probe is { } probe) - await _directory.SaveOnvifMetadataAsync(id, probe, CancellationToken.None).ConfigureAwait(true); - await LoadAsync(CancellationToken.None).ConfigureAwait(true); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to add discovered camera {Host}", req.Host); + var discoveryVm = _discoveryFactory.Create(knownHosts); + var found = await _dialogs.ShowDiscoveryDialogAsync(discoveryVm).ConfigureAwait(true); + // Stop background fingerprints of the instance that just closed. + discoveryVm.Cancel(); + if (found is null) + return; + + // Pre-fill the editor from the probe result so the user sees / can tweak + // everything before saving (RTSP URI especially — phase-04 risks §"ONVIF + // returns wrong RTSP URI behind NAT" applies). + var editor = _editorFactory.CreateForNew(); + editor.Name = found.Device.Model ?? found.Device.Name ?? found.Device.Host; + editor.Host = found.Device.Host; + editor.OnvifPortText = (found.Device.OnvifServiceUri?.Port ?? 80).ToString(System.Globalization.CultureInfo.InvariantCulture); + editor.RtspMainText = found.RtspMainUri.ToString(); + editor.Username = found.Credentials?.Username ?? ""; + editor.Password = found.Credentials?.Password ?? ""; + + var result = await _dialogs.ShowCameraEditorAsync(editor).ConfigureAwait(true); + if (result?.NewRequest is not { } req) + continue; // editor cancelled — back to the scan list + + try + { + var id = await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true); + // Persist HasPtz / ProfileToken / manufacturer info from the probe so + // SingleCameraPage knows whether to show the PTZ joystick (Phase 4c). + // Non-ONVIF devices (sweep/mDNS) have no probe — nothing to persist. + if (found.Probe is { } probe) + await _directory.SaveOnvifMetadataAsync(id, probe, CancellationToken.None).ConfigureAwait(true); + await LoadAsync(CancellationToken.None).ConfigureAwait(true); + knownHosts.Add(req.Host); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add discovered camera {Host}", req.Host); + } } } diff --git a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/DiscoveryDialogViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/DiscoveryDialogViewModel.cs index 83acda9..5e91a0f 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/Dialogs/DiscoveryDialogViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/Dialogs/DiscoveryDialogViewModel.cs @@ -23,11 +23,19 @@ public sealed partial class DiscoveryDialogViewModel : ViewModelBase { private readonly IDiscoveryAggregator _aggregator; private readonly OnvifProbeService _probe; + private readonly OpenIPC.Viewer.Core.Majestic.IMajesticClient _majestic; + private readonly DiscoverySessionCache _cache; + private readonly IReadOnlySet _knownHosts; private readonly ILogger _logger; private CancellationTokenSource? _scanCts; + // Cancels in-flight Majestic fingerprints when the dialog goes away. + private readonly CancellationTokenSource _lifetimeCts = new(); private readonly Dictionary _rowsByHost = new(StringComparer.OrdinalIgnoreCase); + // Hosts we already fingerprinted (or are fingerprinting) — one ping per host. + private readonly HashSet _fingerprinted = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _fingerprintGate = new(6); public ObservableCollection Cameras { get; } = new(); @@ -43,6 +51,10 @@ public sealed partial class DiscoveryDialogViewModel : ViewModelBase [ObservableProperty] private string _username = ""; [ObservableProperty] private string _password = ""; + // "Use these credentials for all cameras" (multi-add). Off → the next + // dialog instance starts with blank login fields. + [ObservableProperty] private bool _reuseCredentials = true; + // 0..1 scan progress (mean across sources). Drives the progress bar; hidden // when not scanning. [ObservableProperty] private double _scanProgress; @@ -66,18 +78,47 @@ public sealed partial class DiscoveryDialogViewModel : ViewModelBase public DiscoveryDialogViewModel( IDiscoveryAggregator aggregator, OnvifProbeService probe, + OpenIPC.Viewer.Core.Majestic.IMajesticClient majestic, + DiscoverySessionCache cache, + IReadOnlySet knownHosts, ILogger logger) { _aggregator = aggregator; _probe = probe; + _majestic = majestic; + _cache = cache; + _knownHosts = knownHosts; _logger = logger; + + // Rehydrate the previous scan so the user can add several cameras + // one-by-one without rescanning between dialog opens. Credentials only + // carry over while "use for all cameras" is on — a mixed-credential + // park starts each camera with blank fields. + _reuseCredentials = cache.ReuseCredentials; + if (_reuseCredentials) + { + _username = cache.Username; + _password = cache.Password; + } + _deepScan = cache.DeepScan; + foreach (var device in cache.Snapshot()) + Upsert(device); + if (Cameras.Count > 0) + StatusText = string.Format(Localizer.Instance["Discovery.Status.FoundFormat"], Cameras.Count); } + partial void OnUsernameChanged(string value) => _cache.Username = value; + partial void OnPasswordChanged(string value) => _cache.Password = value; + partial void OnDeepScanChanged(bool value) => _cache.DeepScan = value; + partial void OnReuseCredentialsChanged(bool value) => _cache.ReuseCredentials = value; + [RelayCommand(CanExecute = nameof(CanScan))] private async Task ScanAsync() { Cameras.Clear(); _rowsByHost.Clear(); + _fingerprinted.Clear(); + _cache.Clear(); Selected = null; ScanProgress = 0; StatusText = Localizer.Instance["Discovery.Status.Scanning"]; @@ -125,10 +166,63 @@ private void Upsert(DiscoveredDevice device) } else { - row = new DiscoveredDeviceRowVm(device); + row = new DiscoveredDeviceRowVm(device) + { + IsAlreadyAdded = _knownHosts.Contains(device.Host), + }; _rowsByHost[device.Host] = row; Cameras.Add(row); } + + _cache.Put(row.Device); + ScheduleFingerprint(row.Device); + } + + // Newer OpenIPC firmwares always run the Majestic web UI, so an HTTP ping + // identifies them even when they answer neither ONVIF nor mDNS with a + // model. One bounded background ping per host; on a hit the row upgrades + // in place (Majestic protocol + "OpenIPC" label + High confidence). + private void ScheduleFingerprint(DiscoveredDevice device) + { + if (device.Protocols.HasFlag(DiscoveryProtocol.Majestic) && device.Model is not null) + return; + if (!_fingerprinted.Add(device.Host)) + return; + + var ct = _lifetimeCts.Token; + _ = Task.Run(async () => + { + await _fingerprintGate.WaitAsync(ct).ConfigureAwait(false); + try + { + var hit = device.Protocols.HasFlag(DiscoveryProtocol.Majestic); + var ports = device.Ports.Where(p => p is 80 or 8080).DefaultIfEmpty(80); + foreach (var port in ports) + { + if (hit) break; + hit = await _majestic.PingAsync( + new OpenIPC.Viewer.Core.Majestic.MajesticEndpoint(device.Host, port, null), ct).ConfigureAwait(false); + } + if (!hit) return; + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + if (!_rowsByHost.TryGetValue(device.Host, out var row)) return; + row.Device = row.Device.MergeWith(new DiscoveredDevice( + device.Host, DiscoveryProtocol.Majestic, Array.Empty(), Model: "OpenIPC")); + _cache.Put(row.Device); + }); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + _logger.LogDebug(ex, "Majestic fingerprint failed for {Host}", device.Host); + } + finally + { + _fingerprintGate.Release(); + } + }, ct); } private bool CanScan() => !ScanInProgress && !AddInProgress; @@ -199,6 +293,7 @@ private static Uri GuessRtspUri(DiscoveredDevice device) public void Cancel() { _scanCts?.Cancel(); + _lifetimeCts.Cancel(); } } @@ -212,6 +307,10 @@ public sealed partial class DiscoveredDeviceRowVm : ViewModelBase [NotifyPropertyChangedFor(nameof(ConfidenceText))] private DiscoveredDevice _device; + // A camera with this host already exists in the library — shown as a badge + // so the multi-add flow makes it obvious what's left to add. + [ObservableProperty] private bool _isAlreadyAdded; + public DiscoveredDeviceRowVm(DiscoveredDevice device) => _device = device; public string Host => Device.Host; diff --git a/src/OpenIPC.Viewer.App/Views/Dialogs/DiscoveryDialogContent.axaml b/src/OpenIPC.Viewer.App/Views/Dialogs/DiscoveryDialogContent.axaml index b7d7078..849bf30 100644 --- a/src/OpenIPC.Viewer.App/Views/Dialogs/DiscoveryDialogContent.axaml +++ b/src/OpenIPC.Viewer.App/Views/Dialogs/DiscoveryDialogContent.axaml @@ -62,9 +62,19 @@ - + + + + + + @@ -89,17 +99,23 @@ - - - - - - - - - - + + + + + + + + + + + + +