Add collector settings and per-source purge
Every limit the fetcher was using was a constant. They are settings now, and CollectOptions became the single place policy lives: AppSettings.ToCollectOptions clamps them, and the HTTP layer's FetchOptions is projected from that. One clamping site rather than two sets of ceilings drifting apart. Clamping rather than validating, for the reason the proxy options already do it: a hand-edited file must not stop the app from starting. A MaxItemBytes edited to zero would otherwise refuse everything, and a zeroed concurrency would deadlock the run outright - so both are pulled into range instead. An empty format filter is read as "everything", because switching every format off is far more likely to be a slip than an instruction to collect nothing. The media root has an ordering problem - it is a setting that decides the paths the container is built from - so the file is read once before the container exists rather than making every path lazy for one value. Purge is scoped to a source and lives on the Collect page, where the source is already chosen. Content another source also holds survives, which is what the index's reference count was for. The showcase hint says out loud what a hard link means: editing the browsable copy edits the original, and deleting it frees nothing until the last name goes. That is surprising enough to belong in the UI rather than only in the code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
70fb3a1df3
commit
fe62bcf53f
@@ -2,7 +2,86 @@ using AvParser.Core.Parsing;
|
||||
|
||||
namespace AvParser.Core.Collecting;
|
||||
|
||||
/// <summary>How to run one collection.</summary>
|
||||
/// <summary>Which formats to keep.</summary>
|
||||
/// <remarks>
|
||||
/// Flags rather than a collection so that <c>AppSettings</c> keeps value equality — the settings
|
||||
/// service short-circuits a no-op write by comparing records, and a list-valued member would make
|
||||
/// every save look like a change. Same reasoning as <c>ProxyProtocolFilter</c>.
|
||||
/// </remarks>
|
||||
[Flags]
|
||||
public enum MediaKindFilter
|
||||
{
|
||||
/// <summary>Nothing. Treated as <see cref="All"/> rather than collecting nothing at all.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>JPEG.</summary>
|
||||
Jpeg = 1,
|
||||
|
||||
/// <summary>PNG, including animated PNG.</summary>
|
||||
Png = 2,
|
||||
|
||||
/// <summary>GIF.</summary>
|
||||
Gif = 4,
|
||||
|
||||
/// <summary>WebP.</summary>
|
||||
WebP = 8,
|
||||
|
||||
/// <summary>AVIF.</summary>
|
||||
Avif = 16,
|
||||
|
||||
/// <summary>MP4, which is what most sites serve when they say "GIF".</summary>
|
||||
Mp4 = 32,
|
||||
|
||||
/// <summary>WebM.</summary>
|
||||
WebM = 64,
|
||||
|
||||
/// <summary>Every still or animated picture.</summary>
|
||||
Images = Jpeg | Png | Gif | WebP | Avif,
|
||||
|
||||
/// <summary>Every video container.</summary>
|
||||
Videos = Mp4 | WebM,
|
||||
|
||||
/// <summary>Everything recognised.</summary>
|
||||
All = Images | Videos,
|
||||
}
|
||||
|
||||
/// <summary>Helpers over <see cref="MediaKindFilter"/>.</summary>
|
||||
public static class MediaKindFilters
|
||||
{
|
||||
/// <summary>The flag standing for one kind.</summary>
|
||||
public static MediaKindFilter ToFlag(MediaKind kind) =>
|
||||
kind switch
|
||||
{
|
||||
MediaKind.Jpeg => MediaKindFilter.Jpeg,
|
||||
MediaKind.Png => MediaKindFilter.Png,
|
||||
MediaKind.Gif => MediaKindFilter.Gif,
|
||||
MediaKind.WebP => MediaKindFilter.WebP,
|
||||
MediaKind.Avif => MediaKindFilter.Avif,
|
||||
MediaKind.Mp4 => MediaKindFilter.Mp4,
|
||||
MediaKind.WebM => MediaKindFilter.WebM,
|
||||
_ => MediaKindFilter.None,
|
||||
};
|
||||
|
||||
/// <summary>Expands a filter into the set of kinds it admits.</summary>
|
||||
public static IReadOnlySet<MediaKind> ToSet(MediaKindFilter filter)
|
||||
{
|
||||
var effective = filter == MediaKindFilter.None ? MediaKindFilter.All : filter;
|
||||
|
||||
return new HashSet<MediaKind>(
|
||||
Enum.GetValues<MediaKind>().Where(kind => kind != MediaKind.Unknown && effective.HasFlag(ToFlag(kind)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How to run one collection: what to accept, how hard to push, and when to give up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every ceiling here exists because the other side chooses the bytes. A missing cap is not a
|
||||
/// generous default, it is a remote party deciding how much of the user's disk to fill. Built by
|
||||
/// <c>AppSettings.ToCollectOptions()</c>, which clamps rather than throws so that a hand-edited
|
||||
/// settings file cannot stop the app from starting.
|
||||
/// </remarks>
|
||||
public sealed record CollectOptions
|
||||
{
|
||||
/// <summary>How many downloads may be in flight at once, across all hosts.</summary>
|
||||
@@ -12,8 +91,44 @@ public sealed record CollectOptions
|
||||
/// </remarks>
|
||||
public int MaxConcurrentDownloads { get; init; } = 4;
|
||||
|
||||
/// <summary>How many requests one origin may be serving at once.</summary>
|
||||
public int MaxConcurrentPerHost { get; init; } = 2;
|
||||
|
||||
/// <summary>Shortest gap between two requests to the same origin.</summary>
|
||||
public TimeSpan HostDelay { get; init; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>Ignore the journal and fetch every address again.</summary>
|
||||
public bool ForceRefetch { get; init; }
|
||||
|
||||
/// <summary>Largest item to accept.</summary>
|
||||
public long MaxItemBytes { get; init; } = 32L * 1024 * 1024;
|
||||
|
||||
/// <summary>Smallest item to accept; below this it is a tracking pixel, not media.</summary>
|
||||
public long MinItemBytes { get; init; } = 1024;
|
||||
|
||||
/// <summary>How many redirects to follow before giving up.</summary>
|
||||
public int MaxRedirects { get; init; } = 5;
|
||||
|
||||
/// <summary>Time allowed to establish a connection.</summary>
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>Time allowed for the response headers to arrive.</summary>
|
||||
public TimeSpan HeaderTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Longest gap between two body reads before the transfer is called stalled.</summary>
|
||||
public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(20);
|
||||
|
||||
/// <summary>Formats to keep.</summary>
|
||||
public MediaKindFilter AllowedKinds { get; init; } = MediaKindFilter.All;
|
||||
|
||||
/// <summary>Identifies the collector to origins that care.</summary>
|
||||
public string UserAgent { get; init; } = "AvParser/0.1";
|
||||
|
||||
/// <summary>Whether a missing proxy is a hard failure rather than a direct connection.</summary>
|
||||
public bool RequireProxy { get; init; }
|
||||
|
||||
/// <summary>How browsable copies point at their blobs.</summary>
|
||||
public ShowcaseMode ShowcaseMode { get; init; } = ShowcaseMode.HardLink;
|
||||
}
|
||||
|
||||
/// <summary>Runs a source end to end: discover, download, store.</summary>
|
||||
|
||||
@@ -83,6 +83,13 @@ public interface IMediaStore
|
||||
/// <summary>Creates or upgrades the schema. Safe to call repeatedly.</summary>
|
||||
Task InitialiseAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Applies the user's showcase preference.</summary>
|
||||
/// <remarks>
|
||||
/// Applied rather than injected, for the same reason <c>IProxyPool.Configure</c> exists: it
|
||||
/// changes while the app runs, and a snapshot taken at container build would freeze it.
|
||||
/// </remarks>
|
||||
void Configure(ShowcaseMode mode);
|
||||
|
||||
/// <summary>Opens a run and returns its id.</summary>
|
||||
Task<string> BeginRunAsync(string sourceId, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AvParser.Core.Collecting;
|
||||
using AvParser.Core.Proxies;
|
||||
|
||||
namespace AvParser.Core.Settings;
|
||||
@@ -49,6 +50,18 @@ public enum AppTheme
|
||||
/// <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>
|
||||
/// <param name="MaxConcurrentPerHost">How many requests one origin may be serving at once.</param>
|
||||
/// <param name="HostDelayMs">Shortest gap between two requests to the same origin, in milliseconds.</param>
|
||||
/// <param name="MaxItemBytes">Largest item to accept, in bytes.</param>
|
||||
/// <param name="MinItemBytes">Smallest item to accept, in bytes.</param>
|
||||
/// <param name="MaxRedirects">How many redirects to follow before giving up.</param>
|
||||
/// <param name="ConnectTimeoutSeconds">Time allowed to establish a connection.</param>
|
||||
/// <param name="HeaderTimeoutSeconds">Time allowed for response headers to arrive.</param>
|
||||
/// <param name="IdleTimeoutSeconds">Longest gap between body reads before a transfer is called stalled.</param>
|
||||
/// <param name="AllowedMediaKinds">Formats to keep.</param>
|
||||
/// <param name="ShowcaseMode">How browsable copies point at their blobs.</param>
|
||||
/// <param name="CollectUserAgent">Identifies the collector to origins that care.</param>
|
||||
/// <param name="MediaRootOverride">Where collected media goes; null keeps it beside the settings.</param>
|
||||
public sealed record AppSettings(
|
||||
AppTheme Theme = AppTheme.System,
|
||||
AppLanguage Language = AppLanguage.System,
|
||||
@@ -67,7 +80,19 @@ public sealed record AppSettings(
|
||||
int ProxyMinimumLive = 10,
|
||||
bool AllowDirectConnection = false,
|
||||
string? LastSourceId = null,
|
||||
int MaxConcurrentDownloads = 4
|
||||
int MaxConcurrentDownloads = 4,
|
||||
int MaxConcurrentPerHost = 2,
|
||||
int HostDelayMs = 250,
|
||||
long MaxItemBytes = 33_554_432,
|
||||
long MinItemBytes = 1024,
|
||||
int MaxRedirects = 5,
|
||||
int ConnectTimeoutSeconds = 15,
|
||||
int HeaderTimeoutSeconds = 30,
|
||||
int IdleTimeoutSeconds = 20,
|
||||
MediaKindFilter AllowedMediaKinds = MediaKindFilter.All,
|
||||
ShowcaseMode ShowcaseMode = ShowcaseMode.HardLink,
|
||||
string CollectUserAgent = "AvParser/0.1",
|
||||
string? MediaRootOverride = null
|
||||
)
|
||||
{
|
||||
/// <summary>Projects the proxy-related settings onto <see cref="ProxyOptions"/>.</summary>
|
||||
@@ -95,4 +120,31 @@ public sealed record AppSettings(
|
||||
AllowDirectConnection = AllowDirectConnection,
|
||||
}.Validated();
|
||||
}
|
||||
|
||||
/// <summary>Projects the collector settings onto <see cref="CollectOptions"/>.</summary>
|
||||
/// <remarks>
|
||||
/// The one place primitives become policy, and it clamps rather than throws for the same reason
|
||||
/// <see cref="ToProxyOptions"/> does: a hand-edited settings file must not be able to stop the
|
||||
/// app from starting. A ceiling edited to zero would otherwise mean "accept nothing" or, worse,
|
||||
/// "accept anything".
|
||||
/// </remarks>
|
||||
public CollectOptions ToCollectOptions() =>
|
||||
new()
|
||||
{
|
||||
MaxConcurrentDownloads = Math.Clamp(MaxConcurrentDownloads, 1, 32),
|
||||
MaxConcurrentPerHost = Math.Clamp(MaxConcurrentPerHost, 1, 16),
|
||||
HostDelay = TimeSpan.FromMilliseconds(Math.Clamp(HostDelayMs, 0, 60_000)),
|
||||
MaxItemBytes = Math.Clamp(MaxItemBytes, 1024, 2L * 1024 * 1024 * 1024),
|
||||
MinItemBytes = Math.Clamp(MinItemBytes, 0, 1024 * 1024),
|
||||
MaxRedirects = Math.Clamp(MaxRedirects, 0, 20),
|
||||
ConnectTimeout = TimeSpan.FromSeconds(Math.Clamp(ConnectTimeoutSeconds, 1, 120)),
|
||||
HeaderTimeout = TimeSpan.FromSeconds(Math.Clamp(HeaderTimeoutSeconds, 1, 300)),
|
||||
IdleTimeout = TimeSpan.FromSeconds(Math.Clamp(IdleTimeoutSeconds, 1, 300)),
|
||||
// An empty filter means the user has switched everything off, which is far more likely
|
||||
// to be an accident than an intention to collect nothing.
|
||||
AllowedKinds = AllowedMediaKinds == MediaKindFilter.None ? MediaKindFilter.All : AllowedMediaKinds,
|
||||
UserAgent = string.IsNullOrWhiteSpace(CollectUserAgent) ? "AvParser/0.1" : CollectUserAgent,
|
||||
RequireProxy = !AllowDirectConnection,
|
||||
ShowcaseMode = ShowcaseMode,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user