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