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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6909884851
commit
70fb3a1df3
@@ -47,6 +47,8 @@ public enum AppTheme
|
||||
/// <param name="ProxyProbeConcurrency">How many probes run at once during a pool sweep.</param>
|
||||
/// <param name="ProxyMinimumLive">How many working proxies a startup warm-up aims for.</param>
|
||||
/// <param name="AllowDirectConnection">Whether network parsers may run without a proxy.</param>
|
||||
/// <param name="LastSourceId">Id of the media source selected last time; resolved leniently on load.</param>
|
||||
/// <param name="MaxConcurrentDownloads">How many downloads may be in flight at once.</param>
|
||||
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
|
||||
)
|
||||
{
|
||||
/// <summary>Projects the proxy-related settings onto <see cref="ProxyOptions"/>.</summary>
|
||||
|
||||
@@ -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<IProxyPool>(),
|
||||
sp.GetRequiredService<ILocalizationService>()
|
||||
));
|
||||
services.AddSingleton<CollectViewModel>(static sp => new CollectViewModel(
|
||||
sp.GetRequiredService<IMediaSourceCatalog>(),
|
||||
sp.GetRequiredService<ISettingsService>(),
|
||||
sp.GetRequiredService<IProxyPool>(),
|
||||
sp.GetRequiredService<ICollectRunner>(),
|
||||
sp,
|
||||
sp.GetRequiredService<ILogger<CollectViewModel>>()
|
||||
));
|
||||
services.AddSingleton<ProxiesViewModel>(static sp => new ProxiesViewModel(
|
||||
sp.GetRequiredService<IProxyPool>(),
|
||||
sp.GetRequiredService<IMutableProxySource>(),
|
||||
@@ -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<PageViewModel>(static sp => sp.GetRequiredService<DashboardViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ParseViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<CollectViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ProxiesViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<SettingsViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<AboutViewModel>());
|
||||
|
||||
@@ -508,4 +508,172 @@
|
||||
<data name="Settings.MinimumLiveHint" xml:space="preserve">
|
||||
<value>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.</value>
|
||||
</data>
|
||||
<data name="Page.Collect" xml:space="preserve">
|
||||
<value>Collect</value>
|
||||
</data>
|
||||
<data name="Collect.Source" xml:space="preserve">
|
||||
<value>SOURCE</value>
|
||||
</data>
|
||||
<data name="Collect.Actions" xml:space="preserve">
|
||||
<value>ACTIONS</value>
|
||||
</data>
|
||||
<data name="Collect.Run" xml:space="preserve">
|
||||
<value>Collect</value>
|
||||
</data>
|
||||
<data name="Collect.Cancel" xml:space="preserve">
|
||||
<value>Stop</value>
|
||||
</data>
|
||||
<data name="Collect.ClearTip" xml:space="preserve">
|
||||
<value>Clear the results</value>
|
||||
</data>
|
||||
<data name="Collect.Input" xml:space="preserve">
|
||||
<value>ADDRESSES</value>
|
||||
</data>
|
||||
<data name="Collect.InputPlaceholder" xml:space="preserve">
|
||||
<value>One address per line. Lines starting with '#' are ignored.</value>
|
||||
</data>
|
||||
<data name="Collect.Endpoint" xml:space="preserve">
|
||||
<value>LISTING ENDPOINT</value>
|
||||
</data>
|
||||
<data name="Collect.EndpointPlaceholder" xml:space="preserve">
|
||||
<value>https://my-service.local/api/list</value>
|
||||
</data>
|
||||
<data name="Collect.ForceRefetch" xml:space="preserve">
|
||||
<value>Fetch everything again</value>
|
||||
</data>
|
||||
<data name="Collect.ForceRefetchHint" xml:space="preserve">
|
||||
<value>Normally an address that was already settled is skipped without a request. Turn this on to ignore that.</value>
|
||||
</data>
|
||||
<data name="Collect.Items" xml:space="preserve">
|
||||
<value>COLLECTED</value>
|
||||
</data>
|
||||
<data name="Collect.Errors" xml:space="preserve">
|
||||
<value>ERRORS</value>
|
||||
</data>
|
||||
<data name="Collect.ProxyRequired" xml:space="preserve">
|
||||
<value>This source needs a working proxy and none is live. Check the proxy list, or allow direct connections in settings.</value>
|
||||
</data>
|
||||
<data name="Collect.GoToProxies" xml:space="preserve">
|
||||
<value>Open proxies</value>
|
||||
</data>
|
||||
<data name="Collect.Item.Stored" xml:space="preserve">
|
||||
<value>new</value>
|
||||
</data>
|
||||
<data name="Collect.Item.Duplicate" xml:space="preserve">
|
||||
<value>duplicate</value>
|
||||
</data>
|
||||
<data name="Collect.Item.Skipped" xml:space="preserve">
|
||||
<value>skipped</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Done" xml:space="preserve">
|
||||
<value>Collected {0} in {1} s.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Cancelled" xml:space="preserve">
|
||||
<value>Stopped after {0} in {1} s.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Duplicates" xml:space="preserve">
|
||||
<value>{0} already held.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Skipped" xml:space="preserve">
|
||||
<value>{0} skipped as already settled.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Failed" xml:space="preserve">
|
||||
<value>{0} failed.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Truncated" xml:space="preserve">
|
||||
<value>The list stops at {0}; the counts above are complete.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Crashed" xml:space="preserve">
|
||||
<value>Collection failed: {0}</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Images.One" xml:space="preserve">
|
||||
<value>{0} image</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Images.Few" xml:space="preserve">
|
||||
<value>{0} images</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Images.Many" xml:space="preserve">
|
||||
<value>{0} images</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Errors.One" xml:space="preserve">
|
||||
<value>{0} error</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Errors.Few" xml:space="preserve">
|
||||
<value>{0} errors</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Errors.Many" xml:space="preserve">
|
||||
<value>{0} errors</value>
|
||||
</data>
|
||||
<data name="Source.url-list.Name" xml:space="preserve">
|
||||
<value>URL list</value>
|
||||
</data>
|
||||
<data name="Source.url-list.Description" xml:space="preserve">
|
||||
<value>Addresses you paste in, one per line.</value>
|
||||
</data>
|
||||
<data name="Source.own-service.Name" xml:space="preserve">
|
||||
<value>Own service</value>
|
||||
</data>
|
||||
<data name="Source.own-service.Description" xml:space="preserve">
|
||||
<value>Reads the listing endpoint of a service you run.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NotAnAddress" xml:space="preserve">
|
||||
<value>'{0}' is not an http or https address.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NoEndpoint" xml:space="preserve">
|
||||
<value>No listing endpoint was given.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.ListingFailed" xml:space="preserve">
|
||||
<value>Could not read the listing: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.ListingMalformed" xml:space="preserve">
|
||||
<value>The listing could not be understood: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.SourceFailed" xml:space="preserve">
|
||||
<value>The source stopped: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NoProxy" xml:space="preserve">
|
||||
<value>No live proxy, and direct connections are switched off.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.BadRedirect" xml:space="preserve">
|
||||
<value>Redirected somewhere that is not an http address.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RedirectLoop" xml:space="preserve">
|
||||
<value>The redirects loop back on themselves.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.TooManyRedirects" xml:space="preserve">
|
||||
<value>Too many redirects.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NotMedia" xml:space="preserve">
|
||||
<value>The response was not an image or a video.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.TooLarge" xml:space="preserve">
|
||||
<value>Larger than the size limit.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.TooSmall" xml:space="preserve">
|
||||
<value>Smaller than the size floor; probably a tracking pixel.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Truncated" xml:space="preserve">
|
||||
<value>The transfer ended early.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Stalled" xml:space="preserve">
|
||||
<value>Timed out.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RateLimited" xml:space="preserve">
|
||||
<value>The site asked to slow down.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Placeholder" xml:space="preserve">
|
||||
<value>A known dead-link placeholder.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.UnsupportedType" xml:space="preserve">
|
||||
<value>That format is switched off.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.HttpStatus" xml:space="preserve">
|
||||
<value>The site answered {0}.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RequestFailed" xml:space="preserve">
|
||||
<value>The request failed: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Failed" xml:space="preserve">
|
||||
<value>Could not be collected.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -508,4 +508,172 @@
|
||||
<data name="Settings.MinimumLiveHint" xml:space="preserve">
|
||||
<value>При старте сначала проверяются те, что работали в прошлый раз, и проверка прекращается, как только набралось столько. Больше значение — дольше первый запуск.</value>
|
||||
</data>
|
||||
<data name="Page.Collect" xml:space="preserve">
|
||||
<value>Сбор</value>
|
||||
</data>
|
||||
<data name="Collect.Source" xml:space="preserve">
|
||||
<value>ИСТОЧНИК</value>
|
||||
</data>
|
||||
<data name="Collect.Actions" xml:space="preserve">
|
||||
<value>ДЕЙСТВИЯ</value>
|
||||
</data>
|
||||
<data name="Collect.Run" xml:space="preserve">
|
||||
<value>Собрать</value>
|
||||
</data>
|
||||
<data name="Collect.Cancel" xml:space="preserve">
|
||||
<value>Остановить</value>
|
||||
</data>
|
||||
<data name="Collect.ClearTip" xml:space="preserve">
|
||||
<value>Очистить результаты</value>
|
||||
</data>
|
||||
<data name="Collect.Input" xml:space="preserve">
|
||||
<value>АДРЕСА</value>
|
||||
</data>
|
||||
<data name="Collect.InputPlaceholder" xml:space="preserve">
|
||||
<value>По одному адресу в строке. Строки, начинающиеся с «#», игнорируются.</value>
|
||||
</data>
|
||||
<data name="Collect.Endpoint" xml:space="preserve">
|
||||
<value>АДРЕС ЛИСТИНГА</value>
|
||||
</data>
|
||||
<data name="Collect.EndpointPlaceholder" xml:space="preserve">
|
||||
<value>https://my-service.local/api/list</value>
|
||||
</data>
|
||||
<data name="Collect.ForceRefetch" xml:space="preserve">
|
||||
<value>Скачать всё заново</value>
|
||||
</data>
|
||||
<data name="Collect.ForceRefetchHint" xml:space="preserve">
|
||||
<value>Обычно уже обработанный адрес пропускается без запроса. Включите, чтобы игнорировать это.</value>
|
||||
</data>
|
||||
<data name="Collect.Items" xml:space="preserve">
|
||||
<value>СОБРАНО</value>
|
||||
</data>
|
||||
<data name="Collect.Errors" xml:space="preserve">
|
||||
<value>ОШИБКИ</value>
|
||||
</data>
|
||||
<data name="Collect.ProxyRequired" xml:space="preserve">
|
||||
<value>Этому источнику нужна рабочая прокси, а живых нет. Проверьте список прокси или разрешите прямое подключение в настройках.</value>
|
||||
</data>
|
||||
<data name="Collect.GoToProxies" xml:space="preserve">
|
||||
<value>Открыть прокси</value>
|
||||
</data>
|
||||
<data name="Collect.Item.Stored" xml:space="preserve">
|
||||
<value>новое</value>
|
||||
</data>
|
||||
<data name="Collect.Item.Duplicate" xml:space="preserve">
|
||||
<value>дубль</value>
|
||||
</data>
|
||||
<data name="Collect.Item.Skipped" xml:space="preserve">
|
||||
<value>пропущено</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Done" xml:space="preserve">
|
||||
<value>Собрано {0} за {1} с.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Cancelled" xml:space="preserve">
|
||||
<value>Остановлено после {0} за {1} с.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Duplicates" xml:space="preserve">
|
||||
<value>{0} уже было.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Skipped" xml:space="preserve">
|
||||
<value>{0} пропущено как уже обработанное.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Failed" xml:space="preserve">
|
||||
<value>{0} не удалось.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Truncated" xml:space="preserve">
|
||||
<value>Список обрывается на {0}; счётчики выше полные.</value>
|
||||
</data>
|
||||
<data name="Collect.Status.Crashed" xml:space="preserve">
|
||||
<value>Сбор не удался: {0}</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Images.One" xml:space="preserve">
|
||||
<value>{0} изображение</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Images.Few" xml:space="preserve">
|
||||
<value>{0} изображения</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Images.Many" xml:space="preserve">
|
||||
<value>{0} изображений</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Errors.One" xml:space="preserve">
|
||||
<value>{0} ошибка</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Errors.Few" xml:space="preserve">
|
||||
<value>{0} ошибки</value>
|
||||
</data>
|
||||
<data name="Collect.Count.Errors.Many" xml:space="preserve">
|
||||
<value>{0} ошибок</value>
|
||||
</data>
|
||||
<data name="Source.url-list.Name" xml:space="preserve">
|
||||
<value>Список ссылок</value>
|
||||
</data>
|
||||
<data name="Source.url-list.Description" xml:space="preserve">
|
||||
<value>Адреса, которые вы вставляете сами, по одному в строке.</value>
|
||||
</data>
|
||||
<data name="Source.own-service.Name" xml:space="preserve">
|
||||
<value>Свой сервис</value>
|
||||
</data>
|
||||
<data name="Source.own-service.Description" xml:space="preserve">
|
||||
<value>Читает листинг сервиса, который вы держите сами.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NotAnAddress" xml:space="preserve">
|
||||
<value>«{0}» — не http- и не https-адрес.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NoEndpoint" xml:space="preserve">
|
||||
<value>Не указан адрес листинга.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.ListingFailed" xml:space="preserve">
|
||||
<value>Не удалось прочитать листинг: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.ListingMalformed" xml:space="preserve">
|
||||
<value>Листинг не удалось разобрать: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.SourceFailed" xml:space="preserve">
|
||||
<value>Источник остановился: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NoProxy" xml:space="preserve">
|
||||
<value>Живых прокси нет, а прямое подключение выключено.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.BadRedirect" xml:space="preserve">
|
||||
<value>Перенаправление на адрес, который не является http.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RedirectLoop" xml:space="preserve">
|
||||
<value>Перенаправления зациклены.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.TooManyRedirects" xml:space="preserve">
|
||||
<value>Слишком много перенаправлений.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.NotMedia" xml:space="preserve">
|
||||
<value>В ответе не изображение и не видео.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.TooLarge" xml:space="preserve">
|
||||
<value>Больше предельного размера.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.TooSmall" xml:space="preserve">
|
||||
<value>Меньше нижнего порога; вероятно, трекинг-пиксель.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Truncated" xml:space="preserve">
|
||||
<value>Передача оборвалась.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Stalled" xml:space="preserve">
|
||||
<value>Истекло время ожидания.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RateLimited" xml:space="preserve">
|
||||
<value>Сайт попросил снизить темп.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Placeholder" xml:space="preserve">
|
||||
<value>Известная заглушка мёртвой ссылки.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.UnsupportedType" xml:space="preserve">
|
||||
<value>Этот формат отключён.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.HttpStatus" xml:space="preserve">
|
||||
<value>Сайт ответил {0}.</value>
|
||||
</data>
|
||||
<data name="Parse.Error.RequestFailed" xml:space="preserve">
|
||||
<value>Запрос не удался: {0}</value>
|
||||
</data>
|
||||
<data name="Parse.Error.Failed" xml:space="preserve">
|
||||
<value>Не удалось собрать.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
M6 2h9l5 5v15H6V2zm8 1.5V8h4.5L14 3.5zM8 12h8v1.6H8V12zm0 3.4h8V17H8v-1.6z
|
||||
</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconDownload">
|
||||
M11 3h2v8.2l3.1-3.1 1.4 1.4L12 15l-5.5-5.5 1.4-1.4L11 11.2V3zM4 18h16v2H4v-2z
|
||||
</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconImage">
|
||||
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
|
||||
</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconSettings">
|
||||
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
|
||||
</StreamGeometry>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Runs a media source and streams what it collects into the UI.</summary>
|
||||
public partial class CollectViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
/// <summary>Rows buffered before being pushed to the UI collection in one go.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private const int BatchSize = 64;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<CollectViewModel> _logger;
|
||||
private readonly ISequencer _mainThread;
|
||||
private readonly ObservableAsPropertyHelper<bool> _isBusy;
|
||||
|
||||
private readonly Signal<RxVoid> _proxyChanged = new();
|
||||
|
||||
private CancellationTokenSource? _cancellation;
|
||||
|
||||
/// <summary>Addresses pasted by the user, one per line.</summary>
|
||||
[Reactive]
|
||||
public partial string InputText { get; set; }
|
||||
|
||||
/// <summary>Listing endpoint, for sources that ask a service what it holds.</summary>
|
||||
[Reactive]
|
||||
public partial string EndpointText { get; set; }
|
||||
|
||||
/// <summary>Source applied by <see cref="CollectCommand"/>.</summary>
|
||||
[Reactive]
|
||||
public partial MediaSourceViewModel SelectedSource { get; set; }
|
||||
|
||||
/// <summary>Completion of the running collection, 0.0 to 1.0.</summary>
|
||||
[Reactive]
|
||||
public partial double Progress { get; set; }
|
||||
|
||||
/// <summary>Whether the total is unknown, so the bar should not pretend to know it.</summary>
|
||||
[Reactive]
|
||||
public partial bool IsProgressIndeterminate { get; set; }
|
||||
|
||||
/// <summary>Outcome summary shown under the toolbar; <see langword="null"/> when idle.</summary>
|
||||
[Reactive]
|
||||
public partial string? StatusMessage { get; set; }
|
||||
|
||||
/// <summary>Fetch every address again, ignoring what earlier runs recorded.</summary>
|
||||
[Reactive]
|
||||
public partial bool ForceRefetch { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected source needs the network but has no working proxy to use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[Reactive]
|
||||
public partial bool IsBlockedWithoutProxy { get; set; }
|
||||
|
||||
/// <summary>Creates the page.</summary>
|
||||
/// <param name="catalog">Available sources.</param>
|
||||
/// <param name="settings">Used to remember the selected source.</param>
|
||||
/// <param name="proxyPool">Consulted for the live count that gates network sources.</param>
|
||||
/// <param name="runner">Runs the collection.</param>
|
||||
/// <param name="services">Resolves the navigation service lazily, to keep pages acyclic.</param>
|
||||
/// <param name="logger">Diagnostics.</param>
|
||||
/// <param name="mainThread">
|
||||
/// Scheduler used to marshal collection and progress updates back to the UI thread. Tests pass
|
||||
/// <see cref="ImmediateSequencer.Instance"/> to make everything synchronous.
|
||||
/// </param>
|
||||
public CollectViewModel(
|
||||
IMediaSourceCatalog catalog,
|
||||
ISettingsService settings,
|
||||
IProxyPool proxyPool,
|
||||
ICollectRunner runner,
|
||||
IServiceProvider services,
|
||||
ILogger<CollectViewModel> 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<INavigationService>().NavigateTo<ProxiesViewModel>(),
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string TitleKey => "Page.Collect";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string IconKey => "IconDownload";
|
||||
|
||||
/// <summary>Every registered source, for the picker.</summary>
|
||||
public IReadOnlyList<MediaSourceViewModel> Sources { get; }
|
||||
|
||||
/// <summary>Collected items, capped at <see cref="MaxDisplayedItems"/>.</summary>
|
||||
public ObservableCollection<CollectedItemViewModel> Items { get; } = [];
|
||||
|
||||
/// <summary>Per-item failures. A failure never aborts the run.</summary>
|
||||
public ObservableCollection<ParseErrorViewModel> Errors { get; } = [];
|
||||
|
||||
/// <summary>Whether a collection is currently running.</summary>
|
||||
public bool IsBusy => _isBusy.Value;
|
||||
|
||||
/// <summary>Runs <see cref="SelectedSource"/>.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> CollectCommand { get; }
|
||||
|
||||
/// <summary>Stops the running collection.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
|
||||
|
||||
/// <summary>Clears the results.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> ClearCommand { get; }
|
||||
|
||||
/// <summary>Takes the user to the page where the proxy problem can be fixed.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> GoToProxiesCommand { get; }
|
||||
|
||||
/// <summary>Explains why collecting is blocked.</summary>
|
||||
public string ProxyRequiredMessage => Localizer.Instance["Collect.ProxyRequired"];
|
||||
|
||||
/// <summary>Re-evaluates the proxy gate. Exposed so tests can drive it without waiting.</summary>
|
||||
public void RefreshProxyGate()
|
||||
{
|
||||
var settings = _settings.Current;
|
||||
|
||||
IsBlockedWithoutProxy =
|
||||
SelectedSource.RequiresNetwork && !settings.AllowDirectConnection && _proxyPool.LiveCount == 0;
|
||||
}
|
||||
|
||||
/// <summary>Whether the source has been given enough to work with.</summary>
|
||||
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<CollectedItemViewModel>(BatchSize);
|
||||
var errorBuffer = new List<ParseError>(16);
|
||||
var progress = new Progress<ParseProgress>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the outcome line out of translated, plural-aware fragments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<CollectedItemViewModel> items, List<ParseError> 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The pool is a singleton and would otherwise keep this page alive for the process.</remarks>
|
||||
public void Dispose()
|
||||
{
|
||||
_proxyPool.Changed -= OnProxyPoolChanged;
|
||||
_proxyChanged.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Marshals a mutation onto the UI thread; the run loop is on the thread pool.</summary>
|
||||
private void OnUi(Action action) => _mainThread.Schedule(action);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Globalization;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.UI.Localization;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>One row of the collected list.</summary>
|
||||
public sealed class CollectedItemViewModel(CollectedItem item) : ReactiveObject
|
||||
{
|
||||
/// <summary>The underlying result.</summary>
|
||||
public CollectedItem Item { get; } = item ?? throw new ArgumentNullException(nameof(item));
|
||||
|
||||
/// <summary>Position within the listing.</summary>
|
||||
public int Ordinal => Item.Candidate.Ordinal;
|
||||
|
||||
/// <summary>Address it came from.</summary>
|
||||
public string Address => Item.Candidate.Url.AbsoluteUri;
|
||||
|
||||
/// <summary>Short hash, enough to recognise a blob without filling the row.</summary>
|
||||
public string ShortHash => Item.Blob.Sha256.Length >= 10 ? Item.Blob.Sha256[..10] : Item.Blob.Sha256;
|
||||
|
||||
/// <summary>Whether these bytes were new to the store.</summary>
|
||||
public bool IsNew => Item.Status == CollectStatus.Stored;
|
||||
|
||||
/// <summary>Whether the address was settled by an earlier run and never fetched.</summary>
|
||||
public bool IsSkipped => Item.Status == CollectStatus.Skipped;
|
||||
|
||||
/// <summary>Translated status word.</summary>
|
||||
/// <remarks>
|
||||
/// Under <c>Collect.Item.*</c> rather than <c>Collect.Status.*</c>: the latter holds the
|
||||
/// summary-line fragments, and "skipped" appears in both senses.
|
||||
/// </remarks>
|
||||
public string StatusText => Localizer.Instance[$"Collect.Item.{Item.Status}"];
|
||||
|
||||
/// <summary>Format name, uppercased.</summary>
|
||||
public string KindText =>
|
||||
Item.Blob.Kind == MediaKind.Unknown ? string.Empty : Item.Blob.Kind.ToString().ToUpperInvariant();
|
||||
|
||||
/// <summary>Human-readable size.</summary>
|
||||
public string SizeText => FormatSize(Item.Blob.Length);
|
||||
|
||||
/// <summary>Pixel dimensions, when they were cheap to read.</summary>
|
||||
public string DimensionsText =>
|
||||
Item.Blob is { Width: { } width, Height: { } height }
|
||||
? $"{width.ToString(CultureInfo.CurrentCulture)}×{height.ToString(CultureInfo.CurrentCulture)}"
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>Whether the content has more than one frame.</summary>
|
||||
public bool IsAnimated => Item.Blob.IsAnimated;
|
||||
|
||||
/// <summary>Where the browsable copy lives, if one was made.</summary>
|
||||
public string? ShowcasePath => Item.ShowcasePath;
|
||||
|
||||
/// <summary>Re-reads everything derived from the current language.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
this.RaisePropertyChanged(nameof(StatusText));
|
||||
this.RaisePropertyChanged(nameof(SizeText));
|
||||
this.RaisePropertyChanged(nameof(DimensionsText));
|
||||
}
|
||||
|
||||
/// <summary>Formats a byte count the way a file manager would.</summary>
|
||||
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]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.UI.Localization;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>A media source paired with its translated name and description.</summary>
|
||||
/// <remarks>
|
||||
/// The domain knows nothing about languages, so <see cref="IMediaSource"/> carries English text.
|
||||
/// This looks the id up as <c>Source.{id}.Name</c> 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.
|
||||
/// </remarks>
|
||||
public sealed class MediaSourceViewModel : ReactiveObject
|
||||
{
|
||||
/// <summary>Wraps a source.</summary>
|
||||
public MediaSourceViewModel(IMediaSource source)
|
||||
{
|
||||
Source = source ?? throw new ArgumentNullException(nameof(source));
|
||||
Localizer.Instance.LanguageChanged += OnLanguageChanged;
|
||||
}
|
||||
|
||||
/// <summary>The source itself.</summary>
|
||||
public IMediaSource Source { get; }
|
||||
|
||||
/// <summary>Stable identifier.</summary>
|
||||
public string Id => Source.Id;
|
||||
|
||||
/// <summary>Translated name, or the source's own when untranslated.</summary>
|
||||
public string Name => Localizer.Instance.GetOrDefault($"Source.{Id}.Name", Source.DisplayName);
|
||||
|
||||
/// <summary>Translated description, or the source's own when untranslated.</summary>
|
||||
public string Description => Localizer.Instance.GetOrDefault($"Source.{Id}.Description", Source.Description);
|
||||
|
||||
/// <summary>Whether this source needs a working proxy before it may run.</summary>
|
||||
public bool RequiresNetwork => Source.RequiresNetwork;
|
||||
|
||||
/// <summary>Whether this source reads a listing endpoint rather than pasted text.</summary>
|
||||
public bool UsesEndpoint => Source.RequiresNetwork;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => Name;
|
||||
|
||||
private void OnLanguageChanged(object? sender, EventArgs e)
|
||||
{
|
||||
this.RaisePropertyChanged(nameof(Name));
|
||||
this.RaisePropertyChanged(nameof(Description));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:l="clr-namespace:AvParser.UI.Localization"
|
||||
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
|
||||
xmlns:conv="clr-namespace:AvParser.UI.Converters"
|
||||
x:Class="AvParser.UI.Views.CollectView"
|
||||
x:DataType="vm:CollectViewModel"
|
||||
>
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*">
|
||||
<!-- ===== Toolbar ===== -->
|
||||
<Border Grid.Row="0" Classes="card" Margin="0,0,0,12">
|
||||
<StackPanel Spacing="12">
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="240">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Collect.Source}" />
|
||||
<ComboBox
|
||||
ItemsSource="{Binding Sources}"
|
||||
SelectedItem="{Binding SelectedSource}"
|
||||
IsEnabled="{Binding IsBusy, Converter={x:Static conv:AppConverters.Not}}"
|
||||
HorizontalAlignment="Stretch"
|
||||
>
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MediaSourceViewModel">
|
||||
<TextBlock Text="{Binding Name}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" Margin="0,0,0,8">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Collect.Actions}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="primary" Command="{Binding CollectCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconDownload}" />
|
||||
<TextBlock Text="{l:Loc Collect.Run}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="destructive" Command="{Binding CancelCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconStop}" />
|
||||
<TextBlock Text="{l:Loc Collect.Cancel}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="icon" Command="{Binding ClearCommand}" ToolTip.Tip="{l:Loc Collect.ClearTip}">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconBroom}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
|
||||
<TextBlock Classes="muted" Text="{Binding SelectedSource.Description}" TextWrapping="Wrap" />
|
||||
|
||||
<CheckBox
|
||||
IsChecked="{Binding ForceRefetch}"
|
||||
Content="{l:Loc Collect.ForceRefetch}"
|
||||
ToolTip.Tip="{l:Loc Collect.ForceRefetchHint}"
|
||||
/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ===== Blocked without a proxy ===== -->
|
||||
<Border
|
||||
x:Name="ProxyGateBanner"
|
||||
Grid.Row="1"
|
||||
Classes="card"
|
||||
Margin="0,0,0,12"
|
||||
Background="{DynamicResource AppDangerSoftBrush}"
|
||||
BorderBrush="{DynamicResource AppDangerBrush}"
|
||||
IsVisible="{Binding IsBlockedWithoutProxy}"
|
||||
>
|
||||
<DockPanel LastChildFill="True">
|
||||
<Button
|
||||
DockPanel.Dock="Right"
|
||||
Classes="primary"
|
||||
Margin="16,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding GoToProxiesCommand}"
|
||||
>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconShield}" />
|
||||
<TextBlock Text="{l:Loc Collect.GoToProxies}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<PathIcon
|
||||
Classes="glyph"
|
||||
Data="{DynamicResource IconAlert}"
|
||||
Foreground="{DynamicResource AppDangerBrush}"
|
||||
VerticalAlignment="Center"
|
||||
/>
|
||||
<TextBlock Text="{Binding ProxyRequiredMessage}" TextWrapping="Wrap" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ===== Progress and status ===== -->
|
||||
<StackPanel Grid.Row="2" Spacing="8" Margin="0,0,0,12">
|
||||
<ProgressBar
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding Progress}"
|
||||
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||
IsVisible="{Binding IsBusy}"
|
||||
Height="4"
|
||||
/>
|
||||
<TextBlock
|
||||
Classes="muted"
|
||||
Text="{Binding StatusMessage}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ===== Input and results ===== -->
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,8,1.4*">
|
||||
<Border Grid.Column="0" Classes="card" Padding="0">
|
||||
<DockPanel LastChildFill="True">
|
||||
<!-- IsVisible belongs on the Border, not the caption inside it: hiding only the text
|
||||
leaves the padding and the divider behind as an empty bar. -->
|
||||
<Border
|
||||
DockPanel.Dock="Top"
|
||||
Padding="16,12"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource AppBorderBrush}"
|
||||
IsVisible="{Binding SelectedSource.UsesEndpoint}"
|
||||
>
|
||||
<TextBlock Classes="caption" Text="{l:Loc Collect.Endpoint}" />
|
||||
</Border>
|
||||
|
||||
<!-- Endpoint sources take one address; the rest take a pasted list. -->
|
||||
<TextBox
|
||||
DockPanel.Dock="Top"
|
||||
Margin="16,12"
|
||||
Text="{Binding EndpointText}"
|
||||
PlaceholderText="{l:Loc Collect.EndpointPlaceholder}"
|
||||
IsVisible="{Binding SelectedSource.UsesEndpoint}"
|
||||
/>
|
||||
|
||||
<Border
|
||||
DockPanel.Dock="Top"
|
||||
Padding="16,12"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource AppBorderBrush}"
|
||||
IsVisible="{Binding SelectedSource.UsesEndpoint, Converter={x:Static conv:AppConverters.Not}}"
|
||||
>
|
||||
<TextBlock Classes="caption" Text="{l:Loc Collect.Input}" />
|
||||
</Border>
|
||||
|
||||
<TextBox
|
||||
Text="{Binding InputText}"
|
||||
AcceptsReturn="True"
|
||||
VerticalContentAlignment="Top"
|
||||
AcceptsTab="False"
|
||||
TextWrapping="NoWrap"
|
||||
PlaceholderText="{l:Loc Collect.InputPlaceholder}"
|
||||
BorderThickness="0"
|
||||
Background="Transparent"
|
||||
FontFamily="Cascadia Code,Consolas,Menlo,DejaVu Sans Mono,monospace"
|
||||
FontSize="{DynamicResource FontSizeBody}"
|
||||
IsVisible="{Binding SelectedSource.UsesEndpoint, Converter={x:Static conv:AppConverters.Not}}"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||
/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="Transparent" />
|
||||
|
||||
<Grid Grid.Column="2" RowDefinitions="*,Auto">
|
||||
<Border Grid.Row="0" Classes="card" Padding="0">
|
||||
<DockPanel LastChildFill="True">
|
||||
<Border
|
||||
DockPanel.Dock="Top"
|
||||
Padding="16,12"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource AppBorderBrush}"
|
||||
>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="caption" Text="{l:Loc Collect.Items}" VerticalAlignment="Center" />
|
||||
<Border Classes="chip">
|
||||
<TextBlock Classes="mono caption" Text="{Binding Items.Count}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ListBox ItemsSource="{Binding Items}" Background="Transparent" BorderThickness="0" SelectionMode="Single">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CollectedItemViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<PathIcon
|
||||
Classes="glyph"
|
||||
Data="{DynamicResource IconImage}"
|
||||
VerticalAlignment="Center"
|
||||
Opacity="0.6"
|
||||
/>
|
||||
|
||||
<Border Classes="chip ok" VerticalAlignment="Center" IsVisible="{Binding IsNew}">
|
||||
<TextBlock Classes="caption" Text="{Binding StatusText}" />
|
||||
</Border>
|
||||
<Border
|
||||
Classes="chip"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding IsNew, Converter={x:Static conv:AppConverters.Not}}"
|
||||
>
|
||||
<TextBlock Classes="caption" Text="{Binding StatusText}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock
|
||||
Classes="mono caption"
|
||||
Text="{Binding Address}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxWidth="420"
|
||||
/>
|
||||
|
||||
<Border Classes="chip accent" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono caption" Text="{Binding KindText}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="muted caption" Text="{Binding DimensionsText}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="muted caption" Text="{Binding SizeText}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Classes="card"
|
||||
Margin="0,8,0,0"
|
||||
Padding="0"
|
||||
MaxHeight="180"
|
||||
IsVisible="{Binding Errors.Count, Converter={x:Static conv:AppConverters.IsPositive}}"
|
||||
>
|
||||
<DockPanel LastChildFill="True">
|
||||
<Border
|
||||
DockPanel.Dock="Top"
|
||||
Padding="16,12"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource AppBorderBrush}"
|
||||
>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon
|
||||
Classes="glyph"
|
||||
Data="{DynamicResource IconAlert}"
|
||||
Foreground="{DynamicResource AppDangerBrush}"
|
||||
VerticalAlignment="Center"
|
||||
/>
|
||||
<TextBlock Classes="caption" Text="{l:Loc Collect.Errors}" VerticalAlignment="Center" />
|
||||
<Border Classes="chip danger">
|
||||
<TextBlock Classes="mono caption" Text="{Binding Errors.Count}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ListBox ItemsSource="{Binding Errors}" Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ParseErrorViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Border Classes="chip danger" VerticalAlignment="Center" MinWidth="44">
|
||||
<TextBlock Classes="mono caption" Text="{Binding LineNumber}" HorizontalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBlock Classes="muted" Text="{Binding Text}" VerticalAlignment="Center" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace AvParser.UI.Views;
|
||||
|
||||
/// <summary>Source picker, toolbar and streamed collection results.</summary>
|
||||
public partial class CollectView : UserControl
|
||||
{
|
||||
/// <summary>Creates the view.</summary>
|
||||
public CollectView() => InitializeComponent();
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -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<ParseOutcome<MediaCandidate>> ParseAsync(
|
||||
MediaQuery input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class IdleRunner : ICollectRunner
|
||||
{
|
||||
public async IAsyncEnumerable<ParseOutcome<CollectedItem>> RunAsync(
|
||||
IMediaSource source,
|
||||
MediaQuery query,
|
||||
CollectOptions options,
|
||||
IProgress<ParseProgress>? 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<CollectViewModel>.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<Border>("ProxyGateBanner").ShouldNotBeNull();
|
||||
|
||||
[AvaloniaFact]
|
||||
public void The_page_renders()
|
||||
{
|
||||
var (view, _, _) = ShowPage(networkSource: false);
|
||||
|
||||
view.GetVisualDescendants().OfType<ListBox>().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<Button>().ShouldHaveSingleItem();
|
||||
|
||||
button.Command.ShouldBeSameAs(viewModel.GoToProxiesCommand);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void A_blocked_page_will_not_run_the_collector()
|
||||
{
|
||||
var (view, viewModel, _) = ShowPage(networkSource: true);
|
||||
viewModel.EndpointText = "https://own.test/api/list";
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var run = view.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.First(candidate => ReferenceEquals(candidate.Command, viewModel.CollectCommand));
|
||||
|
||||
run.IsEffectivelyEnabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void An_endpoint_source_shows_an_address_box_rather_than_a_paste_box()
|
||||
{
|
||||
var (view, _, _) = ShowPage(networkSource: true);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
|
||||
|
||||
boxes.ShouldHaveSingleItem();
|
||||
boxes[0].PlaceholderText.ShouldNotBeNull().ShouldContain("api/list");
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public void A_pasted_list_source_shows_the_paste_box()
|
||||
{
|
||||
var (view, _, _) = ShowPage(networkSource: false);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
|
||||
|
||||
boxes.ShouldHaveSingleItem();
|
||||
boxes[0].AcceptsReturn.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Proxies;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Tests.Fakes;
|
||||
using AvParser.UI.ViewModels;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ReactiveUI.Primitives;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
|
||||
namespace AvParser.UI.Tests;
|
||||
|
||||
public class CollectViewModelTests
|
||||
{
|
||||
/// <summary>A runner that returns a canned stream instead of touching a network.</summary>
|
||||
private sealed class FakeRunner : ICollectRunner
|
||||
{
|
||||
public List<ParseOutcome<CollectedItem>> Results { get; } = [];
|
||||
|
||||
public int Runs { get; private set; }
|
||||
|
||||
public CollectOptions? LastOptions { get; private set; }
|
||||
|
||||
public MediaQuery? LastQuery { get; private set; }
|
||||
|
||||
public TimeSpan Delay { get; set; }
|
||||
|
||||
public async IAsyncEnumerable<ParseOutcome<CollectedItem>> RunAsync(
|
||||
IMediaSource source,
|
||||
MediaQuery query,
|
||||
CollectOptions options,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
Runs++;
|
||||
LastOptions = options;
|
||||
LastQuery = query;
|
||||
|
||||
foreach (var result in Results)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (Delay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(Delay, cancellationToken);
|
||||
}
|
||||
|
||||
yield return result;
|
||||
}
|
||||
|
||||
progress?.Report(new ParseProgress(Results.Count, Results.Count));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubSource(string id, string name, bool network) : IMediaSource
|
||||
{
|
||||
public string Id => id;
|
||||
|
||||
public string DisplayName => name;
|
||||
|
||||
public string Description => string.Empty;
|
||||
|
||||
public bool RequiresNetwork => network;
|
||||
|
||||
public bool CanParse(MediaQuery input) => true;
|
||||
|
||||
public async IAsyncEnumerable<ParseOutcome<MediaCandidate>> ParseAsync(
|
||||
MediaQuery input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EmptyServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
private static CollectedItem Item(string url, CollectStatus status, long length = 4096) =>
|
||||
new(
|
||||
new MediaCandidate(new Uri(url)) { SourceId = "url-list", Ordinal = 1 },
|
||||
MediaBlob.Create(new string('a', 64), MediaKind.Png, length),
|
||||
status
|
||||
);
|
||||
|
||||
private static (CollectViewModel Page, FakeRunner Runner, FakeSettingsService Settings) Build(
|
||||
AppSettings? settings = null,
|
||||
IProxyPool? proxyPool = null,
|
||||
bool includeNetworkSource = false
|
||||
)
|
||||
{
|
||||
IMediaSource[] sources = includeNetworkSource
|
||||
? [new StubSource("url-list", "URL list", false), new StubSource("own-service", "Own service", true)]
|
||||
: [new StubSource("url-list", "URL list", false)];
|
||||
|
||||
var catalog = new MediaSourceCatalog(sources, "url-list");
|
||||
var settingsService = new FakeSettingsService(settings);
|
||||
var runner = new FakeRunner();
|
||||
|
||||
var page = new CollectViewModel(
|
||||
catalog,
|
||||
settingsService,
|
||||
proxyPool ?? new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
|
||||
runner,
|
||||
new EmptyServiceProvider(),
|
||||
NullLogger<CollectViewModel>.Instance,
|
||||
ImmediateSequencer.Instance
|
||||
);
|
||||
|
||||
return (page, runner, settingsService);
|
||||
}
|
||||
|
||||
private static Task RunAsync(CollectViewModel page) => page.CollectCommand.Execute().ToTask();
|
||||
|
||||
[Fact]
|
||||
public void The_page_opens_on_the_source_that_needs_no_proxy()
|
||||
{
|
||||
// Otherwise the app lands behind the gate before the user has asked for anything.
|
||||
var (page, _, _) = Build(includeNetworkSource: true);
|
||||
|
||||
page.SelectedSource.Id.ShouldBe("url-list");
|
||||
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_last_used_source_is_restored()
|
||||
{
|
||||
var (page, _, _) = Build(
|
||||
new AppSettings { LastSourceId = "own-service", AllowDirectConnection = true },
|
||||
includeNetworkSource: true
|
||||
);
|
||||
|
||||
page.SelectedSource.Id.ShouldBe("own-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_remembered_source_falls_back_instead_of_throwing()
|
||||
{
|
||||
var (page, _, _) = Build(new AppSettings { LastSourceId = "removed-in-a-past-version" });
|
||||
|
||||
page.SelectedSource.Id.ShouldBe("url-list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Choosing_a_source_remembers_it()
|
||||
{
|
||||
var (page, _, settings) = Build(new AppSettings { AllowDirectConnection = true }, includeNetworkSource: true);
|
||||
|
||||
page.SelectedSource = page.Sources.Single(source => source.Id == "own-service");
|
||||
|
||||
settings.Current.LastSourceId.ShouldBe("own-service");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Collecting_needs_something_to_collect()
|
||||
{
|
||||
var (page, _, _) = Build();
|
||||
var canExecute = true;
|
||||
using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value);
|
||||
|
||||
canExecute.ShouldBeFalse();
|
||||
|
||||
page.InputText = "https://example.test/a.png";
|
||||
canExecute.ShouldBeTrue();
|
||||
|
||||
page.InputText = " ";
|
||||
canExecute.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_endpoint_source_wants_an_address_not_pasted_text()
|
||||
{
|
||||
var (page, _, _) = Build(new AppSettings { AllowDirectConnection = true }, includeNetworkSource: true);
|
||||
page.SelectedSource = page.Sources.Single(source => source.Id == "own-service");
|
||||
|
||||
var canExecute = true;
|
||||
using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value);
|
||||
|
||||
page.InputText = "https://example.test/a.png";
|
||||
canExecute.ShouldBeFalse();
|
||||
|
||||
page.EndpointText = "not an address";
|
||||
canExecute.ShouldBeFalse();
|
||||
|
||||
page.EndpointText = "https://own.test/api/list";
|
||||
canExecute.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Results_land_in_the_list_and_the_summary_counts_them()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
runner.Results.AddRange([
|
||||
ParseOutcome<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)),
|
||||
ParseOutcome<CollectedItem>.Success(Item("https://a.test/2.png", CollectStatus.Duplicate)),
|
||||
ParseOutcome<CollectedItem>.Success(Item("https://a.test/3.png", CollectStatus.Skipped)),
|
||||
]);
|
||||
|
||||
page.InputText = "https://a.test/1.png";
|
||||
await RunAsync(page);
|
||||
|
||||
page.Items.Count.ShouldBe(3);
|
||||
page.Errors.ShouldBeEmpty();
|
||||
var summary = page.StatusMessage.ShouldNotBeNull();
|
||||
summary.ShouldContain("1 image");
|
||||
summary.ShouldContain("already held");
|
||||
summary.ShouldContain("skipped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failures_land_in_the_error_list_without_stopping_the_run()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
runner.Results.AddRange([
|
||||
ParseOutcome<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)),
|
||||
ParseOutcome<CollectedItem>.Failure(ParseError.Create(2, "TooLarge", "too big")),
|
||||
]);
|
||||
|
||||
page.InputText = "https://a.test/1.png";
|
||||
await RunAsync(page);
|
||||
|
||||
page.Items.ShouldHaveSingleItem();
|
||||
page.Errors.ShouldHaveSingleItem().Text.ShouldBe("Larger than the size limit.");
|
||||
page.StatusMessage!.ShouldContain("1 error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_force_refetch_switch_reaches_the_runner()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
page.InputText = "https://a.test/1.png";
|
||||
page.ForceRefetch = true;
|
||||
|
||||
await RunAsync(page);
|
||||
|
||||
runner.LastOptions!.ForceRefetch.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_pasted_text_reaches_the_query()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
page.InputText = "https://a.test/1.png\nhttps://a.test/2.png";
|
||||
|
||||
await RunAsync(page);
|
||||
|
||||
runner.LastQuery!.Text.ShouldContain("2.png");
|
||||
runner.LastQuery.Endpoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_run_replaces_the_previous_results()
|
||||
{
|
||||
var (page, runner, _) = Build();
|
||||
runner.Results.Add(ParseOutcome<CollectedItem>.Success(Item("https://a.test/1.png", CollectStatus.Stored)));
|
||||
page.InputText = "https://a.test/1.png";
|
||||
|
||||
await RunAsync(page);
|
||||
await RunAsync(page);
|
||||
|
||||
page.Items.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_local_source_runs_with_no_proxy_at_all()
|
||||
{
|
||||
var (page, _, _) = Build();
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_network_source_is_blocked_while_nothing_is_live()
|
||||
{
|
||||
var (page, _, _) = Build(new AppSettings { LastSourceId = "own-service" }, includeNetworkSource: true);
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeTrue();
|
||||
|
||||
var canExecute = true;
|
||||
using var subscription = page.CollectCommand.CanExecute.Subscribe(value => canExecute = value);
|
||||
page.EndpointText = "https://own.test/api/list";
|
||||
|
||||
canExecute.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allowing_direct_connections_lifts_the_gate()
|
||||
{
|
||||
var (page, _, _) = Build(
|
||||
new AppSettings { LastSourceId = "own-service", AllowDirectConnection = true },
|
||||
includeNetworkSource: true
|
||||
);
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_network_source_runs_once_a_proxy_answers()
|
||||
{
|
||||
var endpoint = new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080);
|
||||
var pool = new ProxyPool(
|
||||
[new FakeProxySource([endpoint])],
|
||||
new FakeProxyProbe().Set(endpoint, alive: true),
|
||||
new ProxyOptions()
|
||||
);
|
||||
|
||||
var (page, _, _) = Build(new AppSettings { LastSourceId = "own-service" }, pool, includeNetworkSource: true);
|
||||
page.IsBlockedWithoutProxy.ShouldBeTrue();
|
||||
|
||||
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
||||
await pool.WarmUpAsync(1, cancellationToken: TestContext.Current.CancellationToken);
|
||||
page.RefreshProxyGate();
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Switching_away_from_a_network_source_lifts_the_gate()
|
||||
{
|
||||
var (page, _, _) = Build(new AppSettings { LastSourceId = "own-service" }, includeNetworkSource: true);
|
||||
page.IsBlockedWithoutProxy.ShouldBeTrue();
|
||||
|
||||
page.SelectedSource = page.Sources.Single(source => source.Id == "url-list");
|
||||
|
||||
page.IsBlockedWithoutProxy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sizes_read_the_way_a_file_manager_shows_them()
|
||||
{
|
||||
CollectedItemViewModel.FormatSize(0).ShouldBeEmpty();
|
||||
CollectedItemViewModel.FormatSize(512).ShouldBe("512 B");
|
||||
CollectedItemViewModel.FormatSize(2048).ShouldBe("2 KB");
|
||||
CollectedItemViewModel.FormatSize(1024 * 1024 * 3).ShouldBe("3 MB");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user