using System.Buffers;
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using AvParser.Core.Collecting;
using AvParser.Infrastructure.Media;
using AvParser.Infrastructure.Proxies;
using Microsoft.Extensions.Logging;
namespace AvParser.Infrastructure.Collecting;
/// Downloads one candidate, verifies it, and stages it for the store.
public interface IMediaFetcher
{
/// Fetches one candidate.
///
/// Never throws for an ordinary network or content failure — those come back as a
/// so the run continues. Cancellation still propagates.
///
Task FetchAsync(
MediaCandidate candidate,
FetchOptions options,
CancellationToken cancellationToken = default
);
}
///
/// The one place that knows how to get bytes off the network safely.
///
///
///
/// Redirects are followed here because the shared proxy handler disables automatic redirects, and
/// following them by hand is what makes it possible to cap the hops, spot a loop, refuse a jump to
/// a non-HTTP scheme and re-enter the throttle when the host changes.
///
///
/// The invariant everything else depends on: a staged file is returned only when the transfer
/// reached the end and, where the length was knowable, matched it. Every other path deletes it in a
/// finally. A partial image that reached the blob store would be indistinguishable from a
/// real one for ever after.
///
///
public sealed class MediaFetcher(
IProxiedHttpClientFactory clients,
BlobStore blobs,
HostThrottle throttle,
ILogger logger
) : IMediaFetcher
{
/// Big enough that a 32 MB file is a few hundred reads, small enough to pool cheaply.
private const int BufferSize = 64 * 1024;
private readonly IProxiedHttpClientFactory _clients = clients ?? throw new ArgumentNullException(nameof(clients));
private readonly BlobStore _blobs = blobs ?? throw new ArgumentNullException(nameof(blobs));
private readonly HostThrottle _throttle = throttle ?? throw new ArgumentNullException(nameof(throttle));
private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
///
public async Task FetchAsync(
MediaCandidate candidate,
FetchOptions options,
CancellationToken cancellationToken = default
)
{
ArgumentNullException.ThrowIfNull(candidate);
ArgumentNullException.ThrowIfNull(options);
var stopwatch = Stopwatch.StartNew();
using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
budget.CancelAfter(options.MaxItemDuration);
try
{
var result = await FetchCoreAsync(candidate, options, stopwatch, budget.Token, cancellationToken)
.ConfigureAwait(false);
// Cancelling a socket read surfaces as an IOException as often as an
// OperationCanceledException, and the handlers below would otherwise turn a stop into
// an ordinary per-item failure. One check here covers every return path.
cancellationToken.ThrowIfCancellationRequested();
return result;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
// The backstop fired rather than the user: an item that takes minutes is a failure of
// this attempt, not of the run.
return Failure(candidate.Url, SeenOutcome.Timeout, "Stalled", "item budget exceeded", stopwatch);
}
catch (ProxyUnavailableException)
{
return Failure(candidate.Url, SeenOutcome.Failed, "NoProxy", "no live proxy", stopwatch);
}
}
private async Task FetchCoreAsync(
MediaCandidate candidate,
FetchOptions options,
Stopwatch stopwatch,
CancellationToken token,
CancellationToken userToken
)
{
using var leased = await _clients
.LeaseAsync(options.Timeouts, options.RequireProxy, token)
.ConfigureAwait(false);
HttpStatusCode? status = null;
Exception? failure = null;
var bytesReceived = false;
try
{
var visited = new HashSet(StringComparer.OrdinalIgnoreCase);
var current = candidate.Url;
var referer = candidate.Referer;
for (var hop = 0; ; hop++)
{
if (hop > options.MaxRedirects)
{
return Failure(current, SeenOutcome.Failed, "TooManyRedirects", null, stopwatch, leased.ProxyKey);
}
if (!visited.Add(current.AbsoluteUri))
{
return Failure(current, SeenOutcome.Failed, "RedirectLoop", null, stopwatch, leased.ProxyKey);
}
using var turn = await _throttle.AcquireAsync(current, token).ConfigureAwait(false);
using var request = BuildRequest(current, referer, options);
using var headerBudget = CancellationTokenSource.CreateLinkedTokenSource(token);
headerBudget.CancelAfter(options.Timeouts.Headers);
HttpResponseMessage response;
try
{
response = await leased
.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, headerBudget.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!userToken.IsCancellationRequested)
{
failure = new TimeoutException("headers");
return Failure(current, SeenOutcome.Timeout, "Stalled", "headers", stopwatch, leased.ProxyKey);
}
using (response)
{
status = response.StatusCode;
if (IsRedirect(response.StatusCode))
{
var next = ResolveRedirect(current, response.Headers.Location);
if (next is null)
{
return Failure(
current,
SeenOutcome.Failed,
"BadRedirect",
null,
stopwatch,
leased.ProxyKey
);
}
referer = current;
current = next;
continue;
}
if (response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable)
{
var wait = ReadRetryAfter(response.Headers.RetryAfter);
_throttle.ReportRateLimited(current, wait);
return Failure(
current,
SeenOutcome.RateLimited,
"RateLimited",
null,
stopwatch,
leased.ProxyKey,
(int)response.StatusCode
) with
{
RetryAfter = wait,
};
}
if (!response.IsSuccessStatusCode)
{
var outcome = response.StatusCode is HttpStatusCode.Gone or HttpStatusCode.NotFound
? SeenOutcome.Gone
: SeenOutcome.Failed;
return Failure(
current,
outcome,
"HttpStatus",
((int)response.StatusCode).ToString(System.Globalization.CultureInfo.InvariantCulture),
stopwatch,
leased.ProxyKey,
(int)response.StatusCode
);
}
_throttle.ReportSuccess(current);
var declared = response.Content.Headers.ContentLength;
if (declared > options.MaxItemBytes)
{
// Refused without reading a byte of it: the point of a size cap is not to
// download the thing and then decide it was too big.
return Failure(
current,
SeenOutcome.TooLarge,
"TooLarge",
null,
stopwatch,
leased.ProxyKey,
(int)response.StatusCode
);
}
var download = await DownloadAsync(response, current, options, stopwatch, leased, token, userToken)
.ConfigureAwait(false);
bytesReceived = download.BytesReceived;
failure = download.Failure;
return download.Result;
}
}
}
catch (HttpRequestException ex)
{
failure = ex;
return Failure(candidate.Url, SeenOutcome.Failed, "RequestFailed", ex.Message, stopwatch, leased.ProxyKey);
}
catch (IOException ex)
{
failure = ex;
return Failure(candidate.Url, SeenOutcome.Failed, "RequestFailed", ex.Message, stopwatch, leased.ProxyKey);
}
finally
{
var verdict = LeaseVerdicts.Decide(status, failure, bytesReceived, userToken.IsCancellationRequested);
switch (verdict.Verdict)
{
case LeaseVerdict.Success:
leased.Lease?.ReportSuccess(stopwatch.Elapsed);
break;
case LeaseVerdict.Failure:
leased.Lease?.ReportFailure(verdict.Reason);
break;
default:
// Neutral: say nothing, so the pool learns nothing from a cancelled attempt.
break;
}
}
}
private async Task<(FetchResult Result, bool BytesReceived, Exception? Failure)> DownloadAsync(
HttpResponseMessage response,
Uri url,
FetchOptions options,
Stopwatch stopwatch,
LeasedHttpClient leased,
CancellationToken token,
CancellationToken userToken
)
{
var contentType = response.Content.Headers.ContentType?.MediaType;
var status = (int)response.StatusCode;
var declared = response.Content.Headers.ContentLength;
// Automatic decompression strips the encoding, so a declared length that came with one
// described the compressed body and cannot be compared against what we read.
var lengthIsComparable = declared.HasValue && response.Content.Headers.ContentEncoding.Count == 0;
var temp = _blobs.CreateTempPath();
var buffer = ArrayPool.Shared.Rent(BufferSize);
var prefix = new byte[MediaSignatures.PrefixLength];
var prefixLength = 0;
var total = 0L;
var kind = MediaKind.Unknown;
var sniffed = false;
string? hash = null;
Exception? failure = null;
try
{
// Scoped so the file is closed before it is read back below. Writing with FileShare.None
// is deliberate — nothing else may see a partial file — which makes closing it first a
// requirement rather than tidiness.
{
using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
await using var source = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
await using var destination = new FileStream(
temp,
FileMode.Create,
FileAccess.Write,
FileShare.None,
BufferSize,
useAsync: true
);
using var idle = CancellationTokenSource.CreateLinkedTokenSource(token);
while (true)
{
// Rescheduled before every read: the rule is "no bytes for N seconds", not
// "the whole body within N seconds".
idle.CancelAfter(options.Timeouts.Idle);
int read;
try
{
read = await source.ReadAsync(buffer.AsMemory(0, BufferSize), idle.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!userToken.IsCancellationRequested)
{
failure = new TimeoutException("idle");
return (
Failure(url, SeenOutcome.Timeout, "Stalled", null, stopwatch, leased.ProxyKey, status),
total > 0,
failure
);
}
catch (Exception ex) when (ex is HttpRequestException or IOException)
{
// The origin hung up mid-body. When it had declared a length, that is
// precisely a truncated transfer; the distinction matters because a
// truncated file must never reach the store.
failure = ex;
var truncated = lengthIsComparable && total > 0;
return (
Failure(
url,
SeenOutcome.Failed,
truncated ? "Truncated" : "RequestFailed",
truncated ? null : ex.Message,
stopwatch,
leased.ProxyKey,
status
),
total > 0,
failure
);
}
if (read == 0)
{
break;
}
total += read;
if (total > options.MaxItemBytes)
{
return (
Failure(url, SeenOutcome.TooLarge, "TooLarge", null, stopwatch, leased.ProxyKey, status),
true,
null
);
}
if (prefixLength < prefix.Length)
{
var take = Math.Min(prefix.Length - prefixLength, read);
buffer.AsSpan(0, take).CopyTo(prefix.AsSpan(prefixLength));
prefixLength += take;
}
if (!sniffed && prefixLength >= prefix.Length)
{
sniffed = true;
kind = MediaSignatures.Detect(prefix);
if (Reject(kind, prefix, options) is { } early)
{
return (
Failure(
url,
early.Outcome,
early.Code,
contentType,
stopwatch,
leased.ProxyKey,
status
),
true,
null
);
}
}
hasher.AppendData(buffer, 0, read);
await destination.WriteAsync(buffer.AsMemory(0, read), token).ConfigureAwait(false);
}
await destination.FlushAsync(token).ConfigureAwait(false);
hash = Convert.ToHexStringLower(hasher.GetCurrentHash());
}
// A body shorter than the length it announced is a truncated transfer, not a small file.
if (lengthIsComparable && total != declared!.Value)
{
failure = new IOException("truncated");
return (
Failure(url, SeenOutcome.Failed, "Truncated", null, stopwatch, leased.ProxyKey, status),
true,
failure
);
}
// Files shorter than the sniff window never reached the check inside the loop.
if (!sniffed)
{
kind = MediaSignatures.Detect(prefix.AsSpan(0, prefixLength));
if (Reject(kind, prefix.AsSpan(0, prefixLength), options) is { } rejection)
{
return (
Failure(
url,
rejection.Outcome,
rejection.Code,
contentType,
stopwatch,
leased.ProxyKey,
status
),
true,
null
);
}
}
if (total < options.MinItemBytes)
{
return (
Failure(url, SeenOutcome.TooSmall, "TooSmall", null, stopwatch, leased.ProxyKey, status),
true,
null
);
}
if (options.Tombstones.Contains(hash!))
{
return (
Failure(url, SeenOutcome.Placeholder, "Placeholder", null, stopwatch, leased.ProxyKey, status),
true,
null
);
}
var blob = await DescribeAsync(temp, hash!, kind, total, prefix, token).ConfigureAwait(false);
var result = new FetchResult(SeenOutcome.Stored, url)
{
Blob = blob,
TempPath = temp,
HttpStatus = status,
ContentType = contentType,
ProxyKey = leased.ProxyKey,
Elapsed = stopwatch.Elapsed,
};
temp = null; // ownership passes to the caller
return (result, true, null);
}
finally
{
ArrayPool.Shared.Return(buffer);
// Anything that did not run to a verified end leaves nothing behind. This is what keeps
// the blob store free of half-written files.
if (temp is not null)
{
TryDelete(temp);
}
}
}
/// Re-reads the head of the staged file to settle animation and dimensions.
///
/// A second pass rather than a streaming state machine: whether a PNG is animated depends on a
/// chunk that can sit anywhere before the first IDAT, and tracking that across arbitrary
/// read boundaries is a great deal of fiddly code to avoid re-reading a megabyte that is still
/// in the page cache.
///
private static async Task DescribeAsync(
string path,
string hash,
MediaKind kind,
long length,
byte[] prefix,
CancellationToken token
)
{
var blob = MediaBlob.Create(hash, kind, length);
var (width, height) = MediaSignatures.ReadDimensions(kind, prefix);
var headLength = (int)Math.Min(length, MediaSignatures.AnimationScanLimit);
var head = new byte[headLength];
await using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
await stream.ReadExactlyAsync(head.AsMemory(0, headLength), token).ConfigureAwait(false);
}
return blob with
{
Width = width,
Height = height,
IsAnimated = MediaSignatures.DetectAnimation(kind, head),
};
}
private static (SeenOutcome Outcome, string Code)? Reject(
MediaKind kind,
ReadOnlySpan prefix,
FetchOptions options
)
{
if (MediaSignatures.LooksLikeHtml(prefix))
{
// A dead link answered with an error page and a 200 is the normal behaviour of several
// large image hosts; without this the store fills with copies of their apology page.
return (SeenOutcome.NotMedia, "NotMedia");
}
if (kind == MediaKind.Unknown)
{
return (SeenOutcome.NotMedia, "NotMedia");
}
return options.AllowedKinds.Contains(kind) ? null : (SeenOutcome.UnsupportedType, "UnsupportedType");
}
private static HttpRequestMessage BuildRequest(Uri url, Uri? referer, FetchOptions options)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd(options.UserAgent);
request.Headers.Accept.ParseAdd("image/avif,image/webp,image/apng,image/*,video/*;q=0.8,*/*;q=0.5");
if (referer is not null)
{
request.Headers.Referrer = referer;
}
return request;
}
private static bool IsRedirect(HttpStatusCode status) =>
status
is HttpStatusCode.MovedPermanently
or HttpStatusCode.Found
or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect
or HttpStatusCode.PermanentRedirect;
/// Resolves a Location header, refusing anything that is not HTTP.
private static Uri? ResolveRedirect(Uri current, Uri? location)
{
if (location is null)
{
return null;
}
var resolved = location.IsAbsoluteUri ? location : new Uri(current, location);
// data:, file: and friends have no business being the target of a media fetch.
return resolved.Scheme is "http" or "https" ? resolved : null;
}
private static TimeSpan? ReadRetryAfter(RetryConditionHeaderValue? header)
{
if (header is null)
{
return null;
}
var raw = header.Delta is { } delta
? ((int)delta.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture)
: header.Date?.ToString("r", System.Globalization.CultureInfo.InvariantCulture);
return RetryAfter.Parse(raw, DateTimeOffset.UtcNow, out _);
}
private static FetchResult Failure(
Uri url,
SeenOutcome outcome,
string code,
string? detail,
Stopwatch stopwatch,
string? proxyKey = null,
int status = 0
) =>
new(outcome, url)
{
ErrorCode = code,
ErrorDetail = detail,
HttpStatus = status,
ProxyKey = proxyKey,
Elapsed = stopwatch.Elapsed,
};
private void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Could not remove the staged file {Path}", path);
}
}
}