Add media sources and the collect runner, alongside the old parsers
Third step: the collector becomes wireable. Both catalogs coexist for exactly this one step, so ParseViewModel and every existing test stay green while the new domain is proven. IMediaSource reuses the closed-generic trick ITextParser used, and for the same reason - the container cannot resolve an open generic as IEnumerable<T>, so adding a source stays a one-line registration. Its input is a MediaQuery rather than text, because a source that walks a paginated listing needs an endpoint and a cursor, not a string. Sources discover; they do not download. That split is why UrlListSource lives in the domain with no network at all, and why everything hard about fetching lives in one place instead of once per source. The catalog takes an explicit default id. Left to alphabetical order the landing source would be the network one, so the app would open behind the proxy gate before the user had asked for anything. The runner decouples discovery from downloading with a bounded channel - a listing of two hundred thousand items must not materialise because the workers are slower than the source - and owns its workers, waiting for them even when cancelled. Without that a stopped run keeps writing to the store after the page has said it stopped. The own-service listing is read leniently: the service on the other end is the user's own and should not have to be rewritten to match a schema we invented, so both a bare array of addresses and an object with items and a cursor work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1742c094e9
commit
6909884851
@@ -0,0 +1,34 @@
|
||||
using AvParser.Core.Parsing;
|
||||
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>How to run one collection.</summary>
|
||||
public sealed record CollectOptions
|
||||
{
|
||||
/// <summary>How many downloads may be in flight at once, across all hosts.</summary>
|
||||
/// <remarks>
|
||||
/// The per-host cap sits underneath this and is usually what actually binds: a run against a
|
||||
/// single origin is limited by politeness to that origin, not by this number.
|
||||
/// </remarks>
|
||||
public int MaxConcurrentDownloads { get; init; } = 4;
|
||||
|
||||
/// <summary>Ignore the journal and fetch every address again.</summary>
|
||||
public bool ForceRefetch { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Runs a source end to end: discover, download, store.</summary>
|
||||
/// <remarks>
|
||||
/// Yields the same <see cref="ParseOutcome{T}"/> stream the page already knows how to consume, so
|
||||
/// the UI needs no notion of channels, workers or journals.
|
||||
/// </remarks>
|
||||
public interface ICollectRunner
|
||||
{
|
||||
/// <summary>Collects everything <paramref name="source"/> finds for <paramref name="query"/>.</summary>
|
||||
IAsyncEnumerable<ParseOutcome<CollectedItem>> RunAsync(
|
||||
IMediaSource source,
|
||||
MediaQuery query,
|
||||
CollectOptions options,
|
||||
IProgress<ParseProgress>? progress,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using AvParser.Core.Parsing;
|
||||
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>
|
||||
/// Closed, non-generic facade over <see cref="IParser{TInput, TOutput}"/> for media discovery.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Closed for the same reason the text facade was: an open generic interface cannot be resolved as
|
||||
/// <c>IEnumerable<T></c> by the container, so every source implements this and gets registered
|
||||
/// under it. Adding a source stays a one-line change.
|
||||
/// </remarks>
|
||||
public interface IMediaSource : IParser<MediaQuery, MediaCandidate>;
|
||||
|
||||
/// <summary>Read-only view over every registered media source.</summary>
|
||||
public interface IMediaSourceCatalog
|
||||
{
|
||||
/// <summary>All registered sources, ordered by <see cref="IParser{TInput,TOutput}.DisplayName"/>.</summary>
|
||||
IReadOnlyList<IMediaSource> Sources { get; }
|
||||
|
||||
/// <summary>The source used when nothing has been chosen yet.</summary>
|
||||
IMediaSource DefaultSource { get; }
|
||||
|
||||
/// <summary>Finds a source by its stable id; <see langword="null"/> when unknown.</summary>
|
||||
IMediaSource? Find(string? id);
|
||||
|
||||
/// <summary>Finds a source by id, falling back to <see cref="DefaultSource"/>.</summary>
|
||||
IMediaSource FindOrDefault(string? id) => Find(id) ?? DefaultSource;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="IMediaSourceCatalog" />
|
||||
public sealed class MediaSourceCatalog : IMediaSourceCatalog
|
||||
{
|
||||
private readonly Dictionary<string, IMediaSource> _byId;
|
||||
|
||||
/// <summary>Builds a catalog from every source the container resolved.</summary>
|
||||
/// <param name="sources">Registered sources.</param>
|
||||
/// <param name="defaultId">
|
||||
/// Which source to land on. Named explicitly rather than taken as "first alphabetically",
|
||||
/// because that would make the landing page depend on a display name — and would put a network
|
||||
/// source there, so the app would open behind the proxy gate for no reason.
|
||||
/// </param>
|
||||
/// <exception cref="ArgumentException">No sources were registered, or two share an id.</exception>
|
||||
public MediaSourceCatalog(IEnumerable<IMediaSource> sources, string? defaultId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sources);
|
||||
|
||||
Sources = [.. sources.OrderBy(source => source.DisplayName, StringComparer.OrdinalIgnoreCase)];
|
||||
|
||||
if (Sources.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one media source must be registered.", nameof(sources));
|
||||
}
|
||||
|
||||
_byId = new Dictionary<string, IMediaSource>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var source in Sources)
|
||||
{
|
||||
if (!_byId.TryAdd(source.Id, source))
|
||||
{
|
||||
throw new ArgumentException($"Duplicate media source id '{source.Id}'.", nameof(sources));
|
||||
}
|
||||
}
|
||||
|
||||
DefaultSource = Find(defaultId) ?? Sources[0];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<IMediaSource> Sources { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMediaSource DefaultSource { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IMediaSource? Find(string? id) => id is not null && _byId.TryGetValue(id, out var source) ? source : null;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using AvParser.Core.Parsing;
|
||||
|
||||
namespace AvParser.Core.Collecting.Sources;
|
||||
|
||||
/// <summary>
|
||||
/// Reads addresses the user pasted in, one per line.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The only source that touches no network at all, which is why it lives in the domain and why
|
||||
/// <c>RequiresNetwork</c> stays false: it discovers nothing, it just reads what it was handed.
|
||||
/// Downloading those addresses is the fetcher's business and is gated separately.
|
||||
/// </remarks>
|
||||
public sealed class UrlListSource : IMediaSource
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Id => "url-list";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "URL list";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "One address per line. Blank lines and lines starting with '#' are ignored.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanParse(MediaQuery input) =>
|
||||
input is not null && TextLines.Split(input.Text).Any(line => TryParse(line, out _));
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ParseOutcome<MediaCandidate>> ParseAsync(
|
||||
MediaQuery input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
var lines = TextLines.Split(input.Text);
|
||||
var ordinal = 0;
|
||||
var yielded = 0;
|
||||
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var line = lines[index];
|
||||
|
||||
if (TextLines.IsSkippable(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ordinal++;
|
||||
|
||||
if (TryParse(line, out var url))
|
||||
{
|
||||
yield return ParseOutcome<MediaCandidate>.Success(
|
||||
new MediaCandidate(url!) { SourceId = Id, Ordinal = ordinal }
|
||||
);
|
||||
|
||||
yielded++;
|
||||
|
||||
if (input.HasLimit && yielded >= input.Limit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Named rather than silently dropped: a mistyped address in a pasted list of two
|
||||
// hundred is otherwise impossible to find. The line goes in twice on purpose — as
|
||||
// the English fallback message, and as the argument the translation substitutes.
|
||||
var offending = line.Trim();
|
||||
|
||||
yield return ParseOutcome<MediaCandidate>.Failure(
|
||||
ParseError.Create(
|
||||
ordinal,
|
||||
"NotAnAddress",
|
||||
$"'{offending}' is not an http or https address.",
|
||||
offending
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (ordinal % TextLines.ProgressInterval == 0)
|
||||
{
|
||||
progress?.Report(new ParseProgress(index + 1, lines.Length));
|
||||
}
|
||||
|
||||
if (ordinal % TextLines.YieldInterval == 0)
|
||||
{
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(new ParseProgress(lines.Length, lines.Length));
|
||||
}
|
||||
|
||||
/// <summary>Accepts only absolute HTTP addresses.</summary>
|
||||
/// <remarks>
|
||||
/// <c>file:</c> and <c>data:</c> are rejected here as well as at redirect time: a pasted list is
|
||||
/// as likely to have come from somewhere else as to have been typed.
|
||||
/// </remarks>
|
||||
private static bool TryParse(string line, out Uri? url)
|
||||
{
|
||||
url = null;
|
||||
var text = line.Trim();
|
||||
|
||||
if (text.Length == 0 || !Uri.TryCreate(text, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed.Scheme is not ("http" or "https"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
url = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
namespace AvParser.Core.Parsing.Samples;
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>Line-splitting helpers shared by the sample parsers.</summary>
|
||||
/// <summary>Line-splitting helpers for anything that reads a pasted list.</summary>
|
||||
internal static class TextLines
|
||||
{
|
||||
/// <summary>How many records to process between progress reports.</summary>
|
||||
@@ -1,3 +1,5 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Collecting.Sources;
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Parsing.Samples;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -22,6 +24,15 @@ public static class CoreServiceCollectionExtensions
|
||||
services.AddSingleton<ITextParser, KeyValueTextParser>();
|
||||
services.AddSingleton<IParserCatalog, ParserCatalog>();
|
||||
|
||||
services.AddSingleton<IMediaSource, UrlListSource>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Id of the source the collector opens on.</summary>
|
||||
/// <remarks>
|
||||
/// Named rather than left to alphabetical order, which would land on the network source and
|
||||
/// open the page behind the proxy gate before the user has asked for anything.
|
||||
/// </remarks>
|
||||
public const string DefaultMediaSourceId = "url-list";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using AvParser.Core.Collecting;
|
||||
|
||||
namespace AvParser.Core.Parsing.Samples;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using AvParser.Core.Collecting;
|
||||
|
||||
namespace AvParser.Core.Parsing.Samples;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user