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:
@@ -0,0 +1,110 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Collecting.Sources;
|
||||
|
||||
namespace AvParser.Core.Tests.Collecting;
|
||||
|
||||
public class MediaSourceCatalogTests
|
||||
{
|
||||
private sealed class FakeStore(IEnumerable<PatternSourceConfig>? seed = null) : IUserSourceStore
|
||||
{
|
||||
private readonly List<PatternSourceConfig> _configs = seed?.ToList() ?? [];
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public IReadOnlyList<PatternSourceConfig> List() => [.. _configs];
|
||||
|
||||
public Task<PatternSourceConfig> AddAsync(
|
||||
PatternSourceConfig config,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_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);
|
||||
}
|
||||
}
|
||||
|
||||
private static PatternSourceConfig Config(string id, string name)
|
||||
{
|
||||
PatternSourceConfig.TryCreate(
|
||||
name,
|
||||
"https://h/x/",
|
||||
6,
|
||||
8,
|
||||
IdAlphabet.Digits,
|
||||
null,
|
||||
null,
|
||||
allowDirectConnection: false,
|
||||
out var config,
|
||||
id
|
||||
);
|
||||
return config!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_store_is_a_valid_empty_catalog()
|
||||
{
|
||||
using var catalog = new MediaSourceCatalog(new FakeStore());
|
||||
|
||||
catalog.Sources.ShouldBeEmpty();
|
||||
catalog.Find("anything").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sources_are_materialised_from_the_stored_configs()
|
||||
{
|
||||
using var catalog = new MediaSourceCatalog(new FakeStore([Config("s1", "Beta"), Config("s2", "Alpha")]));
|
||||
|
||||
// Ordered by display name.
|
||||
catalog.Sources.Select(s => s.Id).ShouldBe(["s2", "s1"]);
|
||||
catalog.Find("s1").ShouldNotBeNull().DisplayName.ShouldBe("Beta");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Adding_through_the_catalog_rebuilds_and_signals()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
using var catalog = new MediaSourceCatalog(store);
|
||||
var changed = 0;
|
||||
catalog.Changed += (_, _) => changed++;
|
||||
|
||||
await catalog.AddAsync(Config("s1", "One"), TestContext.Current.CancellationToken);
|
||||
|
||||
changed.ShouldBe(1);
|
||||
catalog.Sources.ShouldHaveSingleItem().Id.ShouldBe("s1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Removing_through_the_catalog_drops_the_source()
|
||||
{
|
||||
using var catalog = new MediaSourceCatalog(new FakeStore([Config("s1", "One")]));
|
||||
|
||||
await catalog.RemoveAsync("s1", TestContext.Current.CancellationToken);
|
||||
|
||||
catalog.Sources.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Collecting.Sources;
|
||||
|
||||
namespace AvParser.Core.Tests.Collecting;
|
||||
|
||||
public class PatternMediaSourceTests
|
||||
{
|
||||
private static PatternSourceConfig Config(
|
||||
int min = 6,
|
||||
int max = 8,
|
||||
IdAlphabet alphabet = IdAlphabet.Alphanumeric,
|
||||
string? custom = null,
|
||||
string? extension = ".jpg",
|
||||
string url = "https://imgtest.example/test1/",
|
||||
bool allowDirect = false
|
||||
)
|
||||
{
|
||||
PatternSourceConfig.TryCreate("Test", url, min, max, alphabet, custom, extension, allowDirect, out var config);
|
||||
return config!;
|
||||
}
|
||||
|
||||
private static async Task<List<MediaCandidate>> Collect(IMediaSource source, int limit)
|
||||
{
|
||||
var list = new List<MediaCandidate>();
|
||||
await foreach (var outcome in source.ParseAsync(new MediaQuery(Limit: limit), null, CancellationToken.None))
|
||||
{
|
||||
if (outcome.IsSuccess)
|
||||
{
|
||||
list.Add(outcome.Value!);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_pattern_source_needs_the_network()
|
||||
{
|
||||
new PatternMediaSource(Config()).RequiresNetwork.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generated_ids_stay_within_the_length_range_and_alphabet()
|
||||
{
|
||||
var source = new PatternMediaSource(Config(min: 4, max: 6, alphabet: IdAlphabet.Digits, extension: null));
|
||||
|
||||
var candidates = await Collect(source, 300);
|
||||
|
||||
candidates.Count.ShouldBe(300);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var id = candidate.ExternalId.ShouldNotBeNull();
|
||||
id.Length.ShouldBeInRange(4, 6);
|
||||
id.ShouldAllBe(c => char.IsAsciiDigit(c));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Candidates_hang_the_id_off_the_base_url_with_the_extension()
|
||||
{
|
||||
var source = new PatternMediaSource(Config(min: 6, max: 6, alphabet: IdAlphabet.HexLower, extension: ".png"));
|
||||
|
||||
var candidate = (await Collect(source, 1)).ShouldHaveSingleItem();
|
||||
|
||||
candidate.SourceId.ShouldBe(source.Id);
|
||||
candidate.Url.AbsoluteUri.ShouldBe($"https://imgtest.example/test1/{candidate.ExternalId}.png");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unset_extension_still_lands_on_a_jpg_address()
|
||||
{
|
||||
var source = new PatternMediaSource(Config(min: 6, max: 6, alphabet: IdAlphabet.HexLower, extension: null));
|
||||
|
||||
var candidate = (await Collect(source, 1)).ShouldHaveSingleItem();
|
||||
|
||||
candidate.Url.AbsoluteUri.ShouldBe($"https://imgtest.example/test1/{candidate.ExternalId}.jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_attempt_budget_bounds_how_many_are_generated()
|
||||
{
|
||||
var source = new PatternMediaSource(Config(min: 6, max: 8, alphabet: IdAlphabet.Alphanumeric));
|
||||
|
||||
(await Collect(source, 40)).Count.ShouldBe(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_small_space_is_never_exceeded_however_large_the_budget()
|
||||
{
|
||||
// Digits of length 2 is a hundred ids; asking for a thousand must not loop forever.
|
||||
var source = new PatternMediaSource(Config(min: 2, max: 2, alphabet: IdAlphabet.Digits, extension: null));
|
||||
|
||||
var candidates = await Collect(source, 1000);
|
||||
|
||||
candidates.Count.ShouldBeLessThanOrEqualTo(100);
|
||||
candidates.Select(c => c.ExternalId).Distinct().Count().ShouldBe(candidates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_budget_means_the_source_keeps_going_until_it_is_stopped()
|
||||
{
|
||||
// A limit of zero used to mean "one candidate", which made an unlimited run collect nothing.
|
||||
var source = new PatternMediaSource(Config(min: 8, max: 8, alphabet: IdAlphabet.Alphanumeric));
|
||||
using var stop = new CancellationTokenSource();
|
||||
|
||||
var seen = 0;
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var outcome in source.ParseAsync(new MediaQuery(), null, stop.Token))
|
||||
{
|
||||
outcome.IsSuccess.ShouldBeTrue();
|
||||
|
||||
if (++seen >= 5_000)
|
||||
{
|
||||
await stop.CancelAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The only way an unbounded run ends.
|
||||
}
|
||||
|
||||
seen.ShouldBe(5_000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_small_space_still_bounds_an_unlimited_run()
|
||||
{
|
||||
// Two digits is a hundred ids: with no budget the run must end at the space, not spin on
|
||||
// collisions for ever.
|
||||
var source = new PatternMediaSource(Config(min: 2, max: 2, alphabet: IdAlphabet.Digits, extension: null));
|
||||
|
||||
var candidates = await Collect(source, 0);
|
||||
|
||||
// Not exactly a hundred: the draw is random, so the last few ids may be given up on rather
|
||||
// than waited for. What matters is that the run ends and never repeats itself.
|
||||
candidates.Count.ShouldBeInRange(90, 100);
|
||||
candidates.Select(c => c.ExternalId).Distinct().Count().ShouldBe(candidates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generated_ids_are_unique_within_a_run()
|
||||
{
|
||||
var source = new PatternMediaSource(Config(min: 8, max: 8, alphabet: IdAlphabet.Alphanumeric, extension: null));
|
||||
|
||||
var candidates = await Collect(source, 500);
|
||||
|
||||
candidates.Select(c => c.ExternalId).Distinct().Count().ShouldBe(500);
|
||||
}
|
||||
}
|
||||
|
||||
public class PatternAlphabetTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(IdAlphabet.LettersLower, "abcdefghijklmnopqrstuvwxyz")]
|
||||
[InlineData(IdAlphabet.Digits, "0123456789")]
|
||||
[InlineData(IdAlphabet.HexLower, "0123456789abcdef")]
|
||||
public void Named_sets_resolve_to_their_characters(IdAlphabet alphabet, string expected)
|
||||
{
|
||||
PatternAlphabet.Resolve(alphabet).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Letters_and_alphanumeric_combine_the_sets()
|
||||
{
|
||||
PatternAlphabet.Resolve(IdAlphabet.Letters).Length.ShouldBe(52);
|
||||
PatternAlphabet.Resolve(IdAlphabet.Alphanumeric).Length.ShouldBe(62);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_custom_set_is_de_duplicated_and_stripped_of_separators()
|
||||
{
|
||||
// Repeats would skew the draw; a slash would break the "one path segment" assumption.
|
||||
PatternAlphabet.Resolve(IdAlphabet.Custom, "aab/c c").ShouldBe("abc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_custom_set_resolves_to_nothing()
|
||||
{
|
||||
PatternAlphabet.Resolve(IdAlphabet.Custom, " ").ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class PatternSourceConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_valid_config_normalises_the_base_url_and_extension()
|
||||
{
|
||||
var error = PatternSourceConfig.TryCreate(
|
||||
" My source ",
|
||||
"https://imgtest.example/test1",
|
||||
6,
|
||||
8,
|
||||
IdAlphabet.Alphanumeric,
|
||||
null,
|
||||
"jpg",
|
||||
allowDirectConnection: false,
|
||||
out var config
|
||||
);
|
||||
|
||||
error.ShouldBe(PatternConfigError.None);
|
||||
config.ShouldNotBeNull();
|
||||
config.Name.ShouldBe("My source");
|
||||
config.BaseUrl.AbsoluteUri.ShouldBe("https://imgtest.example/test1/");
|
||||
config.Extension.ShouldBe(".jpg");
|
||||
config.Id.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_blank_extension_becomes_the_default_one()
|
||||
{
|
||||
// The editor shows ".jpg" as the placeholder, so an untouched field means "the usual one",
|
||||
// not "no suffix" — the latter costs a whole run of 404s to discover.
|
||||
var error = PatternSourceConfig.TryCreate(
|
||||
"N",
|
||||
"https://h/x/",
|
||||
6,
|
||||
8,
|
||||
IdAlphabet.Digits,
|
||||
null,
|
||||
" ",
|
||||
allowDirectConnection: false,
|
||||
out var config
|
||||
);
|
||||
|
||||
error.ShouldBe(PatternConfigError.None);
|
||||
config.ShouldNotBeNull().Extension.ShouldBe(PatternSourceConfig.DefaultExtension);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_supplied_id_is_kept()
|
||||
{
|
||||
PatternSourceConfig.TryCreate(
|
||||
"N",
|
||||
"https://h/x/",
|
||||
6,
|
||||
8,
|
||||
IdAlphabet.Digits,
|
||||
null,
|
||||
null,
|
||||
allowDirectConnection: false,
|
||||
out var config,
|
||||
"fixed-id"
|
||||
);
|
||||
|
||||
config.ShouldNotBeNull().Id.ShouldBe("fixed-id");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "https://h/x/", 6, 8, IdAlphabet.Digits, PatternConfigError.NameRequired)]
|
||||
[InlineData("N", "not-a-url", 6, 8, IdAlphabet.Digits, PatternConfigError.BaseUrlInvalid)]
|
||||
[InlineData("N", "ftp://h/x/", 6, 8, IdAlphabet.Digits, PatternConfigError.BaseUrlInvalid)]
|
||||
[InlineData("N", "https://h/x/", 0, 8, IdAlphabet.Digits, PatternConfigError.LengthRangeInvalid)]
|
||||
[InlineData("N", "https://h/x/", 8, 6, IdAlphabet.Digits, PatternConfigError.LengthRangeInvalid)]
|
||||
[InlineData("N", "https://h/x/", 6, 65, IdAlphabet.Digits, PatternConfigError.LengthRangeInvalid)]
|
||||
[InlineData("N", "https://h/x/", 6, 8, IdAlphabet.Custom, PatternConfigError.AlphabetEmpty)]
|
||||
public void Invalid_inputs_are_rejected_with_a_reason(
|
||||
string name,
|
||||
string url,
|
||||
int min,
|
||||
int max,
|
||||
IdAlphabet alphabet,
|
||||
PatternConfigError expected
|
||||
)
|
||||
{
|
||||
var error = PatternSourceConfig.TryCreate(
|
||||
name,
|
||||
url,
|
||||
min,
|
||||
max,
|
||||
alphabet,
|
||||
null,
|
||||
null,
|
||||
allowDirectConnection: false,
|
||||
out var config
|
||||
);
|
||||
|
||||
error.ShouldBe(expected);
|
||||
config.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Collecting.Sources;
|
||||
using AvParser.Core.Parsing;
|
||||
|
||||
namespace AvParser.Core.Tests.Collecting;
|
||||
|
||||
public class UrlListSourceTests
|
||||
{
|
||||
private static readonly IMediaSource Source = new UrlListSource();
|
||||
|
||||
private static async Task<List<ParseOutcome<MediaCandidate>>> RunAsync(string text, int limit = 0)
|
||||
{
|
||||
var results = new List<ParseOutcome<MediaCandidate>>();
|
||||
|
||||
await foreach (
|
||||
var outcome in Source.ParseAsync(
|
||||
new MediaQuery(text, Limit: limit),
|
||||
null,
|
||||
TestContext.Current.CancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
results.Add(outcome);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Each_line_becomes_a_candidate()
|
||||
{
|
||||
var results = await RunAsync("https://example.test/a.png\nhttps://example.test/b.gif");
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results.ShouldAllBe(r => r.IsSuccess);
|
||||
results[0].Value!.Url.AbsoluteUri.ShouldBe("https://example.test/a.png");
|
||||
results[1].Value!.Ordinal.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Blank_lines_and_comments_are_ignored()
|
||||
{
|
||||
var results = await RunAsync(
|
||||
"""
|
||||
# my list
|
||||
https://example.test/a.png
|
||||
|
||||
# indented comment
|
||||
https://example.test/b.png
|
||||
"""
|
||||
);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Whitespace_around_an_address_is_forgiven()
|
||||
{
|
||||
var results = await RunAsync(" https://example.test/a.png ");
|
||||
|
||||
results.ShouldHaveSingleItem().IsSuccess.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_bad_line_is_named_rather_than_dropped()
|
||||
{
|
||||
// In a paste of two hundred addresses a silently skipped typo is unfindable.
|
||||
var results = await RunAsync("https://example.test/a.png\nnot an address\nhttps://example.test/b.png");
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results[1].IsSuccess.ShouldBeFalse();
|
||||
results[1].Error!.Code.ShouldBe("NotAnAddress");
|
||||
results[1].Error!.Arguments.ShouldContain("not an address");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("file:///etc/passwd")]
|
||||
[InlineData("data:image/png;base64,AAAA")]
|
||||
[InlineData("ftp://example.test/a.png")]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
public async Task Only_http_addresses_are_accepted(string line)
|
||||
{
|
||||
// A pasted list is as likely to have come from somewhere else as to have been typed.
|
||||
var results = await RunAsync(line);
|
||||
|
||||
results.ShouldHaveSingleItem().IsSuccess.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_limit_stops_the_listing_early()
|
||||
{
|
||||
var text = string.Join('\n', Enumerable.Range(0, 50).Select(i => $"https://example.test/{i}.png"));
|
||||
|
||||
var results = await RunAsync(text, limit: 5);
|
||||
|
||||
results.Count(r => r.IsSuccess).ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_source_needs_no_network_of_its_own()
|
||||
{
|
||||
// It discovers nothing; downloading what it read is the fetcher's job and is gated there.
|
||||
Source.RequiresNetwork.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://example.test/a.png", true)]
|
||||
[InlineData("# only a comment", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData("nonsense", false)]
|
||||
public void CanParse_answers_without_doing_any_work(string text, bool expected) =>
|
||||
Source.CanParse(new MediaQuery(text)).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_is_honoured()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
await cancellation.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await foreach (var _ in Source.ParseAsync(new MediaQuery("https://a.test/x.png"), null, cancellation.Token))
|
||||
{
|
||||
// Draining is the point; the first move must already throw.
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class MediaSourceCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void Sources_are_ordered_by_display_name()
|
||||
{
|
||||
var catalog = new MediaSourceCatalog([new FakeSource("z", "Zebra"), new FakeSource("a", "Aardvark")]);
|
||||
|
||||
catalog.Sources.Select(s => s.Id).ShouldBe(["a", "z"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_named_default_wins_over_alphabetical_order()
|
||||
{
|
||||
// Otherwise the landing page depends on a display name, and would open on the network
|
||||
// source — behind the proxy gate — before the user has asked for anything.
|
||||
var catalog = new MediaSourceCatalog([new FakeSource("a", "Aardvark"), new FakeSource("z", "Zebra")], "z");
|
||||
|
||||
catalog.DefaultSource.Id.ShouldBe("z");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unknown_default_falls_back_rather_than_throwing()
|
||||
{
|
||||
var catalog = new MediaSourceCatalog([new FakeSource("a", "Aardvark")], "removed-in-a-past-version");
|
||||
|
||||
catalog.DefaultSource.Id.ShouldBe("a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lookup_ignores_case_and_reports_a_miss()
|
||||
{
|
||||
IMediaSourceCatalog catalog = new MediaSourceCatalog([new FakeSource("url-list", "URL list")]);
|
||||
|
||||
catalog.Find("URL-LIST").ShouldNotBeNull();
|
||||
catalog.Find("nope").ShouldBeNull();
|
||||
catalog.FindOrDefault("nope").Id.ShouldBe("url-list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_registration_is_rejected() =>
|
||||
Should.Throw<ArgumentException>(() => new MediaSourceCatalog([]));
|
||||
|
||||
[Fact]
|
||||
public void Two_sources_sharing_an_id_are_rejected() =>
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
new MediaSourceCatalog([new FakeSource("same", "One"), new FakeSource("same", "Two")])
|
||||
);
|
||||
|
||||
private sealed class FakeSource(string id, string name) : IMediaSource
|
||||
{
|
||||
public string Id => id;
|
||||
|
||||
public string DisplayName => name;
|
||||
|
||||
public string Description => string.Empty;
|
||||
|
||||
public bool CanParse(MediaQuery input) => true;
|
||||
|
||||
public async IAsyncEnumerable<ParseOutcome<MediaCandidate>> ParseAsync(
|
||||
MediaQuery input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await Task.Yield();
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user