Refactor media source handling and update collection options

- Updated `IMediaSourceCatalog` to support user-added media sources, allowing dynamic editing and management of sources.
- Removed the `UrlListSource` class as its functionality is now integrated into the new catalog structure.
- Enhanced `CollectOptions` to default `RequireProxy` to true, ensuring stricter handling of proxy requirements.
- Improved error handling in `ParseError` to include a `Subject` field for better context on failures.
- Adjusted dependency injection to reflect changes in media source management, removing old source registrations.
- Introduced background proxy checks to ensure a more robust proxy pool management during collection processes.

These changes streamline the media collection process and improve the overall user experience by providing clearer error reporting and more flexible source management.
This commit is contained in:
Leonid Pershin
2026-08-15 14:20:06 +03:00
parent a4a0ea9a6b
commit eb5061ee23
63 changed files with 5165 additions and 1557 deletions
@@ -1,41 +1,63 @@
using System.Runtime.CompilerServices;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Collecting;
using AvParser.Core.Collecting.Sources;
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;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.HeadlessTests;
public class CollectViewTests
{
private sealed class StubSource(string id, string name, bool network) : IMediaSource
private sealed class FakeUserSourceStore(IEnumerable<PatternSourceConfig>? seed = null) : IUserSourceStore
{
public string Id => id;
private readonly List<PatternSourceConfig> _configs = seed?.ToList() ?? [];
public string DisplayName => name;
public event EventHandler? Changed;
public string Description => "A source";
public IReadOnlyList<PatternSourceConfig> List() => [.. _configs];
public bool RequiresNetwork => network;
public bool CanParse(MediaQuery input) => true;
public async IAsyncEnumerable<ParseOutcome<MediaCandidate>> ParseAsync(
MediaQuery input,
IProgress<ParseProgress>? progress,
[EnumeratorCancellation] CancellationToken cancellationToken
public Task<PatternSourceConfig> AddAsync(
PatternSourceConfig config,
CancellationToken cancellationToken = default
)
{
await Task.Yield();
yield break;
_configs.RemoveAll(c => c.Id == config.Id);
_configs.Add(config);
Changed?.Invoke(this, EventArgs.Empty);
return Task.FromResult(config);
}
public Task<bool> UpdateAsync(PatternSourceConfig config, CancellationToken cancellationToken = default)
{
var index = _configs.FindIndex(c => c.Id == config.Id);
if (index < 0)
{
return Task.FromResult(false);
}
_configs[index] = config;
Changed?.Invoke(this, EventArgs.Empty);
return Task.FromResult(true);
}
public Task<bool> RemoveAsync(string id, CancellationToken cancellationToken = default)
{
var removed = _configs.RemoveAll(c => c.Id == id) > 0;
if (removed)
{
Changed?.Invoke(this, EventArgs.Empty);
}
return Task.FromResult(removed);
}
}
@@ -46,7 +68,7 @@ public class CollectViewTests
MediaQuery query,
CollectOptions options,
IProgress<ParseProgress>? progress,
[EnumeratorCancellation] CancellationToken cancellationToken
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken
)
{
await Task.Yield();
@@ -59,15 +81,34 @@ public class CollectViewTests
public object? GetService(Type serviceType) => null;
}
private static (CollectView View, CollectViewModel ViewModel, Window Window) ShowPage(bool networkSource)
private static PatternSourceConfig Config(string id = "s1", bool allowDirect = false)
{
IMediaSource[] sources = networkSource
? [new StubSource("url-list", "URL list", false), new StubSource("own-service", "Own service", true)]
: [new StubSource("url-list", "URL list", false)];
PatternSourceConfig.TryCreate(
"Test",
"https://imgtest.example/test1/",
6,
8,
IdAlphabet.Alphanumeric,
null,
".jpg",
allowDirect,
out var config,
id
);
return config!;
}
private static (CollectView View, CollectViewModel ViewModel, Window Window) ShowPage(
bool allowDirect,
bool withSource = true
)
{
// Whether a run may go without a proxy is the source's setting now, not the app's.
var store = new FakeUserSourceStore(withSource ? [Config(allowDirect: allowDirect)] : []);
var viewModel = new CollectViewModel(
new MediaSourceCatalog(sources, networkSource ? "own-service" : "url-list"),
new FakeSettingsService(new AppSettings { LastSourceId = networkSource ? "own-service" : "url-list" }),
new MediaSourceCatalog(store),
new FakeSettingsService(new AppSettings { LastSourceId = "s1" }),
new ProxyPool([], new FakeProxyProbe(), new ProxyOptions()),
new IdleRunner(),
new FakeMediaStore(),
@@ -96,26 +137,26 @@ public class CollectViewTests
[AvaloniaFact]
public void The_page_renders()
{
var (view, _, _) = ShowPage(networkSource: false);
var (view, _, _) = ShowPage(allowDirect: true);
view.GetVisualDescendants().OfType<ListBox>().ShouldNotBeEmpty();
}
[AvaloniaFact]
public void No_banner_is_shown_for_a_source_that_needs_no_network()
public void No_banner_is_shown_when_direct_connections_are_allowed()
{
var (view, viewModel, _) = ShowPage(networkSource: false);
var (view, viewModel, _) = ShowPage(allowDirect: true);
viewModel.IsBlockedWithoutProxy.ShouldBeFalse();
Banner(view).IsVisible.ShouldBeFalse();
}
[AvaloniaFact]
public void A_blocked_network_source_puts_the_banner_on_screen()
public void A_blocked_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);
var (view, viewModel, _) = ShowPage(allowDirect: false);
Dispatcher.UIThread.RunJobs();
viewModel.IsBlockedWithoutProxy.ShouldBeTrue();
@@ -125,7 +166,7 @@ public class CollectViewTests
[AvaloniaFact]
public void The_banner_offers_a_way_to_the_proxies_page()
{
var (view, viewModel, _) = ShowPage(networkSource: true);
var (view, viewModel, _) = ShowPage(allowDirect: false);
Dispatcher.UIThread.RunJobs();
var button = Banner(view).GetVisualDescendants().OfType<Button>().ShouldHaveSingleItem();
@@ -136,8 +177,7 @@ public class CollectViewTests
[AvaloniaFact]
public void A_blocked_page_will_not_run_the_collector()
{
var (view, viewModel, _) = ShowPage(networkSource: true);
viewModel.EndpointText = "https://own.test/api/list";
var (view, viewModel, _) = ShowPage(allowDirect: false);
Dispatcher.UIThread.RunJobs();
var run = view.GetVisualDescendants()
@@ -148,26 +188,65 @@ public class CollectViewTests
}
[AvaloniaFact]
public void An_endpoint_source_shows_an_address_box_rather_than_a_paste_box()
public void With_no_sources_the_empty_state_is_shown()
{
var (view, _, _) = ShowPage(networkSource: true);
var (view, _, _) = ShowPage(allowDirect: true, withSource: false);
Dispatcher.UIThread.RunJobs();
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
boxes.ShouldHaveSingleItem();
boxes[0].PlaceholderText.ShouldNotBeNull().ShouldContain("api/list");
var empty = view.FindControl<Border>("EmptyState").ShouldNotBeNull();
empty.IsEffectivelyVisible.ShouldBeTrue();
}
[AvaloniaFact]
public void A_pasted_list_source_shows_the_paste_box()
public void The_log_panel_appears_as_soon_as_there_is_something_to_show()
{
var (view, _, _) = ShowPage(networkSource: false);
// Rendered rather than asserted on the view model: a log nobody can see is the whole failure
// this guards, and an IsVisible binding that never fires breaks no view-model test.
var (view, viewModel, _) = ShowPage(allowDirect: true);
var panel = view.FindControl<Border>("LogPanel").ShouldNotBeNull();
panel.IsVisible.ShouldBeFalse();
viewModel.Log.Add(CollectLogEntryViewModel.Message(CollectLogLevel.Info, "Collect.Log.SourceStarted", "Test"));
Dispatcher.UIThread.RunJobs();
var boxes = view.GetVisualDescendants().OfType<TextBox>().Where(box => box.IsEffectivelyVisible).ToList();
panel.IsEffectivelyVisible.ShouldBeTrue();
view.FindControl<ListBox>("CollectLog").ShouldNotBeNull().ItemCount.ShouldBe(1);
boxes.ShouldHaveSingleItem();
boxes[0].AcceptsReturn.ShouldBeTrue();
// A log that grows without bound pushes the collected list off the page.
panel.Bounds.Height.ShouldBeLessThanOrEqualTo(220);
// Lines have to be selectable in bulk and copyable, which is the point of a log you can
// paste into a bug report.
var list = view.FindControl<ListBox>("CollectLog").ShouldNotBeNull();
list.SelectionMode.HasFlag(SelectionMode.Multiple).ShouldBeTrue();
list.ContextMenu.ShouldNotBeNull().Items.Count.ShouldBe(2);
}
[AvaloniaFact]
public void Every_source_is_offered_with_a_tick_box()
{
var (view, viewModel, _) = ShowPage(allowDirect: true);
var list = view.FindControl<ListBox>("SourceList").ShouldNotBeNull();
list.ItemCount.ShouldBe(1);
viewModel.RunnableSources().ShouldHaveSingleItem();
view.GetVisualDescendants().OfType<CheckBox>().ShouldContain(box => box.IsChecked == true);
// The picker sits in the toolbar: it scrolls, it does not grow with the catalog.
list.Bounds.Height.ShouldBeLessThanOrEqualTo(120);
}
[AvaloniaFact]
public void Adding_a_source_reveals_the_editor()
{
var (view, viewModel, _) = ShowPage(allowDirect: true, withSource: false);
viewModel.AddSourceCommand.Execute().Subscribe();
Dispatcher.UIThread.RunJobs();
var editor = view.FindControl<Border>("SourceEditor").ShouldNotBeNull();
editor.IsEffectivelyVisible.ShouldBeTrue();
}
}