From 70fb3a1df33d6c8e8a3bd0960d781a271c25d286 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 13 Aug 2026 21:39:21 +0300 Subject: [PATCH] Add the Collect page The collector becomes usable. The page is a close copy of the parse page's shape - same proxy gate, same batched flush to the observable collection, same truncation cap that reports rather than truncates silently - because that shape was built for streaming outcomes and the collector produces exactly those. Two differences that are not cosmetic. The batch drops from 512 to 64: items arrive at network speed, roughly one a second, and a batch of five hundred would mean the list never visibly moved. And progress is explicitly indeterminate until a source finishes listing, because a paginated listing genuinely does not know its total until the last page - a bar pretending otherwise would be lying. The input swaps shape with the source: an endpoint source wants one address, a pasted-list source wants many lines. The proxy gate is unchanged in substance - only network sources are gated, and the banner keeps its x:Name because the headless tests find it that way. Rendering the page caught a defect the tests could not: IsVisible sat on the caption inside the header border rather than on the border, so hiding the text left its padding and divider behind as an empty bar above the input. Co-Authored-By: Claude Opus 5 --- src/AvParser.Core/Settings/AppSettings.cs | 6 +- .../UiServiceCollectionExtensions.cs | 10 + src/AvParser.UI/Localization/Strings.resx | 168 +++++++ src/AvParser.UI/Localization/Strings.ru.resx | 168 +++++++ src/AvParser.UI/Styles/Icons.axaml | 8 + .../ViewModels/CollectViewModel.cs | 471 ++++++++++++++++++ .../ViewModels/CollectedItemViewModel.cs | 89 ++++ .../ViewModels/MediaSourceViewModel.cs | 49 ++ src/AvParser.UI/Views/CollectView.axaml | 280 +++++++++++ src/AvParser.UI/Views/CollectView.axaml.cs | 13 + .../CollectViewTests.cs | 171 +++++++ .../CollectViewModelTests.cs | 342 +++++++++++++ 12 files changed, 1774 insertions(+), 1 deletion(-) create mode 100644 src/AvParser.UI/ViewModels/CollectViewModel.cs create mode 100644 src/AvParser.UI/ViewModels/CollectedItemViewModel.cs create mode 100644 src/AvParser.UI/ViewModels/MediaSourceViewModel.cs create mode 100644 src/AvParser.UI/Views/CollectView.axaml create mode 100644 src/AvParser.UI/Views/CollectView.axaml.cs create mode 100644 tests/AvParser.UI.HeadlessTests/CollectViewTests.cs create mode 100644 tests/AvParser.UI.Tests/CollectViewModelTests.cs diff --git a/src/AvParser.Core/Settings/AppSettings.cs b/src/AvParser.Core/Settings/AppSettings.cs index 65b7236..ab6f69b 100644 --- a/src/AvParser.Core/Settings/AppSettings.cs +++ b/src/AvParser.Core/Settings/AppSettings.cs @@ -47,6 +47,8 @@ public enum AppTheme /// How many probes run at once during a pool sweep. /// How many working proxies a startup warm-up aims for. /// Whether network parsers may run without a proxy. +/// Id of the media source selected last time; resolved leniently on load. +/// How many downloads may be in flight at once. public sealed record AppSettings( AppTheme Theme = AppTheme.System, AppLanguage Language = AppLanguage.System, @@ -63,7 +65,9 @@ public sealed record AppSettings( int ProxyProbeTimeoutSeconds = 8, int ProxyProbeConcurrency = 64, int ProxyMinimumLive = 10, - bool AllowDirectConnection = false + bool AllowDirectConnection = false, + string? LastSourceId = null, + int MaxConcurrentDownloads = 4 ) { /// Projects the proxy-related settings onto . diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs index 9f3ae4f..434161b 100644 --- a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using AvParser.Core.Collecting; using AvParser.Core.Parsing; using AvParser.Core.Proxies; using AvParser.Core.Settings; @@ -46,6 +47,14 @@ public static class UiServiceCollectionExtensions sp.GetRequiredService(), sp.GetRequiredService() )); + services.AddSingleton(static sp => new CollectViewModel( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp, + sp.GetRequiredService>() + )); services.AddSingleton(static sp => new ProxiesViewModel( sp.GetRequiredService(), sp.GetRequiredService(), @@ -57,6 +66,7 @@ public static class UiServiceCollectionExtensions // Order here is the order of the navigation rail; the first entry is the landing page. services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); services.AddSingleton(static sp => sp.GetRequiredService()); diff --git a/src/AvParser.UI/Localization/Strings.resx b/src/AvParser.UI/Localization/Strings.resx index 1746f3e..f30f4e6 100644 --- a/src/AvParser.UI/Localization/Strings.resx +++ b/src/AvParser.UI/Localization/Strings.resx @@ -508,4 +508,172 @@ Startup checks the ones that worked last time first and stops as soon as it has this many. Raising it makes the first launch slower. + + Collect + + + SOURCE + + + ACTIONS + + + Collect + + + Stop + + + Clear the results + + + ADDRESSES + + + One address per line. Lines starting with '#' are ignored. + + + LISTING ENDPOINT + + + https://my-service.local/api/list + + + Fetch everything again + + + Normally an address that was already settled is skipped without a request. Turn this on to ignore that. + + + COLLECTED + + + ERRORS + + + This source needs a working proxy and none is live. Check the proxy list, or allow direct connections in settings. + + + Open proxies + + + new + + + duplicate + + + skipped + + + Collected {0} in {1} s. + + + Stopped after {0} in {1} s. + + + {0} already held. + + + {0} skipped as already settled. + + + {0} failed. + + + The list stops at {0}; the counts above are complete. + + + Collection failed: {0} + + + {0} image + + + {0} images + + + {0} images + + + {0} error + + + {0} errors + + + {0} errors + + + URL list + + + Addresses you paste in, one per line. + + + Own service + + + Reads the listing endpoint of a service you run. + + + '{0}' is not an http or https address. + + + No listing endpoint was given. + + + Could not read the listing: {0} + + + The listing could not be understood: {0} + + + The source stopped: {0} + + + No live proxy, and direct connections are switched off. + + + Redirected somewhere that is not an http address. + + + The redirects loop back on themselves. + + + Too many redirects. + + + The response was not an image or a video. + + + Larger than the size limit. + + + Smaller than the size floor; probably a tracking pixel. + + + The transfer ended early. + + + Timed out. + + + The site asked to slow down. + + + A known dead-link placeholder. + + + That format is switched off. + + + The site answered {0}. + + + The request failed: {0} + + + Could not be collected. + diff --git a/src/AvParser.UI/Localization/Strings.ru.resx b/src/AvParser.UI/Localization/Strings.ru.resx index c3c4c19..15a1e32 100644 --- a/src/AvParser.UI/Localization/Strings.ru.resx +++ b/src/AvParser.UI/Localization/Strings.ru.resx @@ -508,4 +508,172 @@ При старте сначала проверяются те, что работали в прошлый раз, и проверка прекращается, как только набралось столько. Больше значение — дольше первый запуск. + + Сбор + + + ИСТОЧНИК + + + ДЕЙСТВИЯ + + + Собрать + + + Остановить + + + Очистить результаты + + + АДРЕСА + + + По одному адресу в строке. Строки, начинающиеся с «#», игнорируются. + + + АДРЕС ЛИСТИНГА + + + https://my-service.local/api/list + + + Скачать всё заново + + + Обычно уже обработанный адрес пропускается без запроса. Включите, чтобы игнорировать это. + + + СОБРАНО + + + ОШИБКИ + + + Этому источнику нужна рабочая прокси, а живых нет. Проверьте список прокси или разрешите прямое подключение в настройках. + + + Открыть прокси + + + новое + + + дубль + + + пропущено + + + Собрано {0} за {1} с. + + + Остановлено после {0} за {1} с. + + + {0} уже было. + + + {0} пропущено как уже обработанное. + + + {0} не удалось. + + + Список обрывается на {0}; счётчики выше полные. + + + Сбор не удался: {0} + + + {0} изображение + + + {0} изображения + + + {0} изображений + + + {0} ошибка + + + {0} ошибки + + + {0} ошибок + + + Список ссылок + + + Адреса, которые вы вставляете сами, по одному в строке. + + + Свой сервис + + + Читает листинг сервиса, который вы держите сами. + + + «{0}» — не http- и не https-адрес. + + + Не указан адрес листинга. + + + Не удалось прочитать листинг: {0} + + + Листинг не удалось разобрать: {0} + + + Источник остановился: {0} + + + Живых прокси нет, а прямое подключение выключено. + + + Перенаправление на адрес, который не является http. + + + Перенаправления зациклены. + + + Слишком много перенаправлений. + + + В ответе не изображение и не видео. + + + Больше предельного размера. + + + Меньше нижнего порога; вероятно, трекинг-пиксель. + + + Передача оборвалась. + + + Истекло время ожидания. + + + Сайт попросил снизить темп. + + + Известная заглушка мёртвой ссылки. + + + Этот формат отключён. + + + Сайт ответил {0}. + + + Запрос не удался: {0} + + + Не удалось собрать. + diff --git a/src/AvParser.UI/Styles/Icons.axaml b/src/AvParser.UI/Styles/Icons.axaml index 2c08ab6..356d6f3 100644 --- a/src/AvParser.UI/Styles/Icons.axaml +++ b/src/AvParser.UI/Styles/Icons.axaml @@ -11,6 +11,14 @@ M6 2h9l5 5v15H6V2zm8 1.5V8h4.5L14 3.5zM8 12h8v1.6H8V12zm0 3.4h8V17H8v-1.6z + + M11 3h2v8.2l3.1-3.1 1.4 1.4L12 15l-5.5-5.5 1.4-1.4L11 11.2V3zM4 18h16v2H4v-2z + + + + M3 4h18v16H3V4zm2 2v10.2l4-4 3 3 4-4 3 3V6H5zm3.5 1.5a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z + + M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zm9.4 3.5c0 .5 0 .9-.1 1.3l2 1.6-1.9 3.3-2.4-1a7.6 7.6 0 0 1-2.2 1.3l-.4 2.5h-3.8l-.4-2.5a7.6 7.6 0 0 1-2.2-1.3l-2.4 1-1.9-3.3 2-1.6a7.7 7.7 0 0 1 0-2.6l-2-1.6L5.6 5.8l2.4 1a7.6 7.6 0 0 1 2.2-1.3l.4-2.5h3.8l.4 2.5a7.6 7.6 0 0 1 2.2 1.3l2.4-1 1.9 3.3-2 1.6c.1.4.1.8.1 1.3z diff --git a/src/AvParser.UI/ViewModels/CollectViewModel.cs b/src/AvParser.UI/ViewModels/CollectViewModel.cs new file mode 100644 index 0000000..695d722 --- /dev/null +++ b/src/AvParser.UI/ViewModels/CollectViewModel.cs @@ -0,0 +1,471 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using AvParser.Core.Collecting; +using AvParser.Core.Parsing; +using AvParser.Core.Proxies; +using AvParser.Core.Settings; +using AvParser.UI.Localization; +using AvParser.UI.Navigation; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ReactiveUI; +using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Signals; +using ReactiveUI.SourceGenerators; + +namespace AvParser.UI.ViewModels; + +/// Runs a media source and streams what it collects into the UI. +public partial class CollectViewModel : PageViewModel, IDisposable +{ + /// Rows buffered before being pushed to the UI collection in one go. + /// + /// Far smaller than the parse page's batch: items arrive at network speed, roughly one a + /// second, so a batch of five hundred would mean the list never moved. + /// + private const int BatchSize = 64; + + /// + /// Upper bound on rows shown. Beyond this the run still completes and the counts stay accurate, + /// but the list stops growing — truncation is reported, never silent. + /// + private const int MaxDisplayedItems = 20_000; + + private readonly IMediaSourceCatalog _catalog; + private readonly ISettingsService _settings; + private readonly IProxyPool _proxyPool; + private readonly ICollectRunner _runner; + private readonly IServiceProvider _services; + private readonly ILogger _logger; + private readonly ISequencer _mainThread; + private readonly ObservableAsPropertyHelper _isBusy; + + private readonly Signal _proxyChanged = new(); + + private CancellationTokenSource? _cancellation; + + /// Addresses pasted by the user, one per line. + [Reactive] + public partial string InputText { get; set; } + + /// Listing endpoint, for sources that ask a service what it holds. + [Reactive] + public partial string EndpointText { get; set; } + + /// Source applied by . + [Reactive] + public partial MediaSourceViewModel SelectedSource { get; set; } + + /// Completion of the running collection, 0.0 to 1.0. + [Reactive] + public partial double Progress { get; set; } + + /// Whether the total is unknown, so the bar should not pretend to know it. + [Reactive] + public partial bool IsProgressIndeterminate { get; set; } + + /// Outcome summary shown under the toolbar; when idle. + [Reactive] + public partial string? StatusMessage { get; set; } + + /// Fetch every address again, ignoring what earlier runs recorded. + [Reactive] + public partial bool ForceRefetch { get; set; } + + /// + /// Whether the selected source needs the network but has no working proxy to use. + /// + /// + /// Only network sources are gated. A source that reads addresses the user pasted has nothing to + /// route, and blocking it would make the app unusable whenever the public lists are down. + /// + [Reactive] + public partial bool IsBlockedWithoutProxy { get; set; } + + /// Creates the page. + /// Available sources. + /// Used to remember the selected source. + /// Consulted for the live count that gates network sources. + /// Runs the collection. + /// Resolves the navigation service lazily, to keep pages acyclic. + /// Diagnostics. + /// + /// Scheduler used to marshal collection and progress updates back to the UI thread. Tests pass + /// to make everything synchronous. + /// + public CollectViewModel( + IMediaSourceCatalog catalog, + ISettingsService settings, + IProxyPool proxyPool, + ICollectRunner runner, + IServiceProvider services, + ILogger logger, + ISequencer? mainThread = null + ) + { + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool)); + _runner = runner ?? throw new ArgumentNullException(nameof(runner)); + _services = services ?? throw new ArgumentNullException(nameof(services)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _mainThread = mainThread ?? RxSchedulers.MainThreadScheduler; + + InputText = string.Empty; + EndpointText = string.Empty; + Sources = [.. catalog.Sources.Select(source => new MediaSourceViewModel(source))]; + SelectedSource = Sources.First(source => source.Id == catalog.FindOrDefault(settings.Current.LastSourceId).Id); + + // The pool changes on every lease outcome and on every probe, so coalesce before + // re-evaluating whether the source is allowed to run. + _proxyPool.Changed += OnProxyPoolChanged; + _proxyChanged + .Throttle(TimeSpan.FromMilliseconds(250), _mainThread) + .ObserveOn(_mainThread) + .Subscribe(_ => RefreshProxyGate()); + + RefreshProxyGate(); + + var canCollect = this.WhenAnyValue( + x => x.InputText, + x => x.EndpointText, + x => x.SelectedSource, + x => x.IsBlockedWithoutProxy, + static (text, endpoint, source, blocked) => (text, endpoint, source, blocked) + ) + .Select(static state => !state.blocked && HasWork(state.source, state.text, state.endpoint)) + .DistinctUntilChanged(); + + CollectCommand = ReactiveCommand.CreateFromTask(RunCollectAsync, canCollect, _mainThread); + + GoToProxiesCommand = ReactiveCommand.Create( + () => _services.GetRequiredService().NavigateTo(), + outputScheduler: _mainThread + ); + + _isBusy = CollectCommand.IsExecuting.ToProperty(this, nameof(IsBusy), false, _mainThread); + + CancelCommand = ReactiveCommand.Create(() => _cancellation?.Cancel(), CollectCommand.IsExecuting, _mainThread); + + ClearCommand = ReactiveCommand.Create( + () => + { + ClearResults(); + StatusMessage = null; + Progress = 0d; + }, + CollectCommand.IsExecuting.Select(static running => !running), + _mainThread + ); + + // Remember the choice; the debounced settings service coalesces the writes. Switching + // source can also change whether the gate applies, since only network sources are gated. + this.WhenAnyValue(x => x.SelectedSource) + .Where(static source => source is not null) + .Subscribe(source => + { + _settings.Update(current => current with { LastSourceId = source.Id }); + RefreshProxyGate(); + }); + + _settings.Changes.Subscribe(_ => RefreshProxyGate()); + + CollectCommand.ThrownExceptions.Subscribe(OnCommandFailed); + } + + /// + public override string TitleKey => "Page.Collect"; + + /// + public override string IconKey => "IconDownload"; + + /// Every registered source, for the picker. + public IReadOnlyList Sources { get; } + + /// Collected items, capped at . + public ObservableCollection Items { get; } = []; + + /// Per-item failures. A failure never aborts the run. + public ObservableCollection Errors { get; } = []; + + /// Whether a collection is currently running. + public bool IsBusy => _isBusy.Value; + + /// Runs . + public ReactiveCommand CollectCommand { get; } + + /// Stops the running collection. + public ReactiveCommand CancelCommand { get; } + + /// Clears the results. + public ReactiveCommand ClearCommand { get; } + + /// Takes the user to the page where the proxy problem can be fixed. + public ReactiveCommand GoToProxiesCommand { get; } + + /// Explains why collecting is blocked. + public string ProxyRequiredMessage => Localizer.Instance["Collect.ProxyRequired"]; + + /// Re-evaluates the proxy gate. Exposed so tests can drive it without waiting. + public void RefreshProxyGate() + { + var settings = _settings.Current; + + IsBlockedWithoutProxy = + SelectedSource.RequiresNetwork && !settings.AllowDirectConnection && _proxyPool.LiveCount == 0; + } + + /// Whether the source has been given enough to work with. + private static bool HasWork(MediaSourceViewModel? source, string text, string endpoint) => + source is not null + && ( + source.UsesEndpoint + ? Uri.TryCreate(endpoint, UriKind.Absolute, out var parsed) && parsed.Scheme is "http" or "https" + : !string.IsNullOrWhiteSpace(text) + ); + + private void OnProxyPoolChanged(object? sender, EventArgs e) => _proxyChanged.OnNext(RxVoid.Default); + + private async Task RunCollectAsync(CancellationToken commandToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(commandToken); + _cancellation = cancellation; + + var source = SelectedSource.Source; + var query = BuildQuery(); + var options = new CollectOptions + { + MaxConcurrentDownloads = _settings.Current.MaxConcurrentDownloads, + ForceRefetch = ForceRefetch, + }; + + ClearResults(); + Progress = 0d; + IsProgressIndeterminate = true; + StatusMessage = null; + + var itemBuffer = new List(BatchSize); + var errorBuffer = new List(16); + var progress = new Progress(value => + OnUi(() => + { + IsProgressIndeterminate = value.IsIndeterminate; + Progress = value.Fraction; + }) + ); + + var stopwatch = Stopwatch.StartNew(); + var stored = 0; + var duplicates = 0; + var skipped = 0; + var failed = 0; + var truncated = false; + var cancelled = false; + + try + { + await foreach ( + var outcome in _runner + .RunAsync(source, query, options, progress, cancellation.Token) + .ConfigureAwait(false) + ) + { + if (outcome.IsSuccess) + { + var item = outcome.Value!; + + switch (item.Status) + { + case CollectStatus.Stored: + stored++; + break; + case CollectStatus.Duplicate: + duplicates++; + break; + default: + skipped++; + break; + } + + if (stored + duplicates + skipped <= MaxDisplayedItems) + { + itemBuffer.Add(new CollectedItemViewModel(item)); + } + else + { + truncated = true; + } + } + else + { + failed++; + errorBuffer.Add(outcome.Error); + } + + if (itemBuffer.Count >= BatchSize) + { + FlushBuffers(itemBuffer, errorBuffer); + } + } + } + catch (OperationCanceledException) + { + cancelled = true; + } + finally + { + _cancellation = null; + FlushBuffers(itemBuffer, errorBuffer); + stopwatch.Stop(); + } + + var summary = BuildSummary(stored, duplicates, skipped, failed, stopwatch.Elapsed, truncated, cancelled); + + OnUi(() => + { + StatusMessage = summary; + IsProgressIndeterminate = false; + Progress = cancelled ? Progress : 1d; + }); + + _logger.LogInformation( + "Collected with {Source}: {Stored} new, {Duplicates} duplicate, {Skipped} skipped, {Failed} failed in {Elapsed}", + source.Id, + stored, + duplicates, + skipped, + failed, + stopwatch.Elapsed + ); + } + + private MediaQuery BuildQuery() + { + var endpoint = + SelectedSource.UsesEndpoint && Uri.TryCreate(EndpointText, UriKind.Absolute, out var parsed) + ? parsed + : null; + + return new MediaQuery(InputText, endpoint); + } + + /// + /// Builds the outcome line out of translated, plural-aware fragments. + /// + /// + /// Assembled from pieces rather than one format string per case: Russian needs three forms for + /// a counted noun, so "{0} images" with an English plural glued on cannot be translated. + /// + internal static string BuildSummary( + int stored, + int duplicates, + int skipped, + int failed, + TimeSpan elapsed, + bool truncated, + bool cancelled + ) + { + var loc = Localizer.Instance; + var images = loc.Plural("Collect.Count.Images", stored); + var seconds = elapsed.TotalSeconds.ToString("N1", CultureInfo.CurrentCulture); + + var text = loc.Format(cancelled ? "Collect.Status.Cancelled" : "Collect.Status.Done", images, seconds); + + if (duplicates > 0) + { + text += " " + loc.Format("Collect.Status.Duplicates", loc.Plural("Collect.Count.Images", duplicates)); + } + + if (skipped > 0) + { + text += " " + loc.Format("Collect.Status.Skipped", loc.Plural("Collect.Count.Images", skipped)); + } + + if (failed > 0) + { + text += " " + loc.Format("Collect.Status.Failed", loc.Plural("Collect.Count.Errors", failed)); + } + + if (truncated) + { + text += " " + loc.Format("Collect.Status.Truncated", loc.Plural("Collect.Count.Images", MaxDisplayedItems)); + } + + return text; + } + + private void FlushBuffers(List items, List errors) + { + if (items.Count == 0 && errors.Count == 0) + { + return; + } + + // Copy before clearing: the scheduled callback may run after the loop has refilled these. + var itemBatch = items.ToArray(); + var errorBatch = errors.ToArray(); + items.Clear(); + errors.Clear(); + + OnUi(() => + { + foreach (var item in itemBatch) + { + Items.Add(item); + } + + foreach (var error in errorBatch) + { + Errors.Add(new ParseErrorViewModel(error)); + } + }); + } + + /// + /// The pool is a singleton and would otherwise keep this page alive for the process. + public void Dispose() + { + _proxyPool.Changed -= OnProxyPoolChanged; + _proxyChanged.Dispose(); + GC.SuppressFinalize(this); + } + + /// + protected override void OnLanguageChanged() + { + base.OnLanguageChanged(); + + this.RaisePropertyChanged(nameof(ProxyRequiredMessage)); + + // The summary and the listed rows were all rendered in the previous language. + StatusMessage = null; + + foreach (var item in Items) + { + item.Refresh(); + } + + foreach (var error in Errors) + { + error.Refresh(); + } + } + + private void ClearResults() + { + Items.Clear(); + Errors.Clear(); + } + + private void OnCommandFailed(Exception exception) + { + _logger.LogError(exception, "Collection failed"); + OnUi(() => StatusMessage = Localizer.Instance.Format("Collect.Status.Crashed", exception.Message)); + } + + /// Marshals a mutation onto the UI thread; the run loop is on the thread pool. + private void OnUi(Action action) => _mainThread.Schedule(action); +} diff --git a/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs b/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs new file mode 100644 index 0000000..5d4525e --- /dev/null +++ b/src/AvParser.UI/ViewModels/CollectedItemViewModel.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using AvParser.Core.Collecting; +using AvParser.UI.Localization; +using ReactiveUI; + +namespace AvParser.UI.ViewModels; + +/// One row of the collected list. +public sealed class CollectedItemViewModel(CollectedItem item) : ReactiveObject +{ + /// The underlying result. + public CollectedItem Item { get; } = item ?? throw new ArgumentNullException(nameof(item)); + + /// Position within the listing. + public int Ordinal => Item.Candidate.Ordinal; + + /// Address it came from. + public string Address => Item.Candidate.Url.AbsoluteUri; + + /// Short hash, enough to recognise a blob without filling the row. + public string ShortHash => Item.Blob.Sha256.Length >= 10 ? Item.Blob.Sha256[..10] : Item.Blob.Sha256; + + /// Whether these bytes were new to the store. + public bool IsNew => Item.Status == CollectStatus.Stored; + + /// Whether the address was settled by an earlier run and never fetched. + public bool IsSkipped => Item.Status == CollectStatus.Skipped; + + /// Translated status word. + /// + /// Under Collect.Item.* rather than Collect.Status.*: the latter holds the + /// summary-line fragments, and "skipped" appears in both senses. + /// + public string StatusText => Localizer.Instance[$"Collect.Item.{Item.Status}"]; + + /// Format name, uppercased. + public string KindText => + Item.Blob.Kind == MediaKind.Unknown ? string.Empty : Item.Blob.Kind.ToString().ToUpperInvariant(); + + /// Human-readable size. + public string SizeText => FormatSize(Item.Blob.Length); + + /// Pixel dimensions, when they were cheap to read. + public string DimensionsText => + Item.Blob is { Width: { } width, Height: { } height } + ? $"{width.ToString(CultureInfo.CurrentCulture)}×{height.ToString(CultureInfo.CurrentCulture)}" + : string.Empty; + + /// Whether the content has more than one frame. + public bool IsAnimated => Item.Blob.IsAnimated; + + /// Where the browsable copy lives, if one was made. + public string? ShowcasePath => Item.ShowcasePath; + + /// Re-reads everything derived from the current language. + public void Refresh() + { + this.RaisePropertyChanged(nameof(StatusText)); + this.RaisePropertyChanged(nameof(SizeText)); + this.RaisePropertyChanged(nameof(DimensionsText)); + } + + /// Formats a byte count the way a file manager would. + public static string FormatSize(long bytes) + { + if (bytes <= 0) + { + return string.Empty; + } + + string[] units = ["B", "KB", "MB", "GB"]; + var size = (double)bytes; + var unit = 0; + + while (size >= 1024 && unit < units.Length - 1) + { + size /= 1024; + unit++; + } + + // Whole bytes read oddly with a decimal; everything larger reads oddly without one. + var text = + unit == 0 + ? size.ToString("0", CultureInfo.CurrentCulture) + : size.ToString("0.#", CultureInfo.CurrentCulture); + + return $"{text} {units[unit]}"; + } +} diff --git a/src/AvParser.UI/ViewModels/MediaSourceViewModel.cs b/src/AvParser.UI/ViewModels/MediaSourceViewModel.cs new file mode 100644 index 0000000..1e716cc --- /dev/null +++ b/src/AvParser.UI/ViewModels/MediaSourceViewModel.cs @@ -0,0 +1,49 @@ +using AvParser.Core.Collecting; +using AvParser.UI.Localization; +using ReactiveUI; + +namespace AvParser.UI.ViewModels; + +/// A media source paired with its translated name and description. +/// +/// The domain knows nothing about languages, so carries English text. +/// This looks the id up as Source.{id}.Name and falls back to what the source itself says, +/// which keeps the "add a source = one registration line" promise intact: a new source works +/// untranslated rather than rendering a missing-key marker. +/// +public sealed class MediaSourceViewModel : ReactiveObject +{ + /// Wraps a source. + public MediaSourceViewModel(IMediaSource source) + { + Source = source ?? throw new ArgumentNullException(nameof(source)); + Localizer.Instance.LanguageChanged += OnLanguageChanged; + } + + /// The source itself. + public IMediaSource Source { get; } + + /// Stable identifier. + public string Id => Source.Id; + + /// Translated name, or the source's own when untranslated. + public string Name => Localizer.Instance.GetOrDefault($"Source.{Id}.Name", Source.DisplayName); + + /// Translated description, or the source's own when untranslated. + public string Description => Localizer.Instance.GetOrDefault($"Source.{Id}.Description", Source.Description); + + /// Whether this source needs a working proxy before it may run. + public bool RequiresNetwork => Source.RequiresNetwork; + + /// Whether this source reads a listing endpoint rather than pasted text. + public bool UsesEndpoint => Source.RequiresNetwork; + + /// + public override string ToString() => Name; + + private void OnLanguageChanged(object? sender, EventArgs e) + { + this.RaisePropertyChanged(nameof(Name)); + this.RaisePropertyChanged(nameof(Description)); + } +} diff --git a/src/AvParser.UI/Views/CollectView.axaml b/src/AvParser.UI/Views/CollectView.axaml new file mode 100644 index 0000000..efbdc40 --- /dev/null +++ b/src/AvParser.UI/Views/CollectView.axaml @@ -0,0 +1,280 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Views/CollectView.axaml.cs b/src/AvParser.UI/Views/CollectView.axaml.cs new file mode 100644 index 0000000..f133410 --- /dev/null +++ b/src/AvParser.UI/Views/CollectView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AvParser.UI.Views; + +/// Source picker, toolbar and streamed collection results. +public partial class CollectView : UserControl +{ + /// Creates the view. + public CollectView() => InitializeComponent(); + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs b/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs new file mode 100644 index 0000000..d759624 --- /dev/null +++ b/tests/AvParser.UI.HeadlessTests/CollectViewTests.cs @@ -0,0 +1,171 @@ +using System.Runtime.CompilerServices; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvParser.Core.Collecting; +using AvParser.Core.Parsing; +using AvParser.Core.Proxies; +using AvParser.Core.Settings; +using AvParser.UI.ViewModels; +using AvParser.UI.Views; +using Microsoft.Extensions.Logging.Abstractions; +using ReactiveUI.Primitives.Concurrency; + +namespace AvParser.UI.HeadlessTests; + +public class CollectViewTests +{ + private sealed class StubSource(string id, string name, bool network) : IMediaSource + { + public string Id => id; + + public string DisplayName => name; + + public string Description => "A source"; + + public bool RequiresNetwork => network; + + public bool CanParse(MediaQuery input) => true; + + public async IAsyncEnumerable> ParseAsync( + MediaQuery input, + IProgress? progress, + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + await Task.Yield(); + yield break; + } + } + + private sealed class IdleRunner : ICollectRunner + { + public async IAsyncEnumerable> RunAsync( + IMediaSource source, + MediaQuery query, + CollectOptions options, + IProgress? progress, + [EnumeratorCancellation] CancellationToken cancellationToken + ) + { + await Task.Yield(); + yield break; + } + } + + private sealed class EmptyServiceProvider : IServiceProvider + { + public object? GetService(Type serviceType) => null; + } + + private static (CollectView View, CollectViewModel ViewModel, Window Window) ShowPage(bool networkSource) + { + IMediaSource[] sources = networkSource + ? [new StubSource("url-list", "URL list", false), new StubSource("own-service", "Own service", true)] + : [new StubSource("url-list", "URL list", false)]; + + var viewModel = new CollectViewModel( + new MediaSourceCatalog(sources, networkSource ? "own-service" : "url-list"), + new FakeSettingsService(new AppSettings { LastSourceId = networkSource ? "own-service" : "url-list" }), + new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()), + new IdleRunner(), + new EmptyServiceProvider(), + NullLogger.Instance, + ImmediateSequencer.Instance + ); + + var view = new CollectView { DataContext = viewModel }; + var window = new Window + { + Width = 1400, + Height = 900, + Content = view, + }; + + window.Show(); + Dispatcher.UIThread.RunJobs(); + + return (view, viewModel, window); + } + + private static Border Banner(CollectView view) => view.FindControl("ProxyGateBanner").ShouldNotBeNull(); + + [AvaloniaFact] + public void The_page_renders() + { + var (view, _, _) = ShowPage(networkSource: false); + + view.GetVisualDescendants().OfType().ShouldNotBeEmpty(); + } + + [AvaloniaFact] + public void No_banner_is_shown_for_a_source_that_needs_no_network() + { + var (view, viewModel, _) = ShowPage(networkSource: false); + + viewModel.IsBlockedWithoutProxy.ShouldBeFalse(); + Banner(view).IsVisible.ShouldBeFalse(); + } + + [AvaloniaFact] + public void A_blocked_network_source_puts_the_banner_on_screen() + { + // Rendered rather than asserted on the view model: an IsVisible binding that never fires + // leaves the page silently unhelpful, which is exactly the failure this guards. + var (view, viewModel, _) = ShowPage(networkSource: true); + Dispatcher.UIThread.RunJobs(); + + viewModel.IsBlockedWithoutProxy.ShouldBeTrue(); + Banner(view).IsEffectivelyVisible.ShouldBeTrue(); + } + + [AvaloniaFact] + public void The_banner_offers_a_way_to_the_proxies_page() + { + var (view, viewModel, _) = ShowPage(networkSource: true); + Dispatcher.UIThread.RunJobs(); + + var button = Banner(view).GetVisualDescendants().OfType