213 lines
8.8 KiB
C#
213 lines
8.8 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using dy.net.model.dto;
|
|
using dy.net.service;
|
|
using dy.net.Tests.TestInfrastructure;
|
|
|
|
namespace dy.net.Tests;
|
|
|
|
public class MediaDownloadTests
|
|
{
|
|
[Fact]
|
|
public async Task RedirectToCdn_DoesNotForwardDouyinCookie()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
var handler = new RecordingMediaHandler(request =>
|
|
{
|
|
if (request.RequestUri.Host == "www.douyin.com")
|
|
{
|
|
var redirect = new HttpResponseMessage(HttpStatusCode.Redirect);
|
|
redirect.Headers.Location = new Uri("https://v3.douyinvod.com/media/video.mp4");
|
|
return redirect;
|
|
}
|
|
var success = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new ByteArrayContent(new byte[] { 1, 2, 3, 4 })
|
|
};
|
|
success.Content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4");
|
|
return success;
|
|
});
|
|
using var factory = new StubHttpClientFactory(handler);
|
|
var service = new DouyinHttpClientService(factory, null);
|
|
|
|
var result = await service.DownloadAsync(
|
|
"https://www.douyin.com/aweme/v1/play?id=signed-secret",
|
|
Path.Combine(temporary.Path, "video.mp4"),
|
|
"sessionid=secret-cookie");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
Assert.Equal("sessionid=secret-cookie", handler.Requests[0].Cookie);
|
|
Assert.Null(handler.Requests[1].Cookie);
|
|
Assert.Equal("v3.douyinvod.com", result.SourceHost);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DuplicateCandidates_AreTriedOnceAndAllForbiddenIsStructured()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
var handler = new RecordingMediaHandler(_ => new HttpResponseMessage(HttpStatusCode.Forbidden));
|
|
using var factory = new StubHttpClientFactory(handler);
|
|
var service = new DouyinHttpClientService(factory, null);
|
|
var first = "https://www.douyin.com/media/one?signature=secret-one";
|
|
var second = "https://www.douyin.com/media/two?signature=secret-two";
|
|
|
|
var result = await service.DownloadAsync(first, Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret",
|
|
new List<string> { first, second, second }, maxRetryCount: 12);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal(MediaDownloadFailureKind.SourceForbidden, result.FailureKind);
|
|
Assert.Equal(403, result.HttpStatusCode);
|
|
Assert.Equal(2, result.AttemptedUrlCount);
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MixedCandidateErrors_AreNotCountedAsAllForbidden()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
var handler = new RecordingMediaHandler(request => new HttpResponseMessage(
|
|
request.RequestUri.AbsolutePath.EndsWith("one", StringComparison.Ordinal)
|
|
? HttpStatusCode.NotFound
|
|
: HttpStatusCode.Forbidden));
|
|
using var factory = new StubHttpClientFactory(handler);
|
|
var service = new DouyinHttpClientService(factory, null);
|
|
|
|
var result = await service.DownloadAsync("https://www.douyin.com/media/one",
|
|
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret",
|
|
new List<string> { "https://www.douyin.com/media/two" }, maxRetryCount: 12);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal(MediaDownloadFailureKind.SourceUnavailable, result.FailureKind);
|
|
Assert.Equal(2, result.AttemptedUrlCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unauthorized_StopsWithoutTryingMoreCandidates()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
var handler = new RecordingMediaHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
|
using var factory = new StubHttpClientFactory(handler);
|
|
var service = new DouyinHttpClientService(factory, null);
|
|
|
|
var result = await service.DownloadAsync("https://www.douyin.com/media/one",
|
|
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret",
|
|
new List<string> { "https://www.douyin.com/media/two" });
|
|
|
|
Assert.Equal(MediaDownloadFailureKind.SourceUnauthorized, result.FailureKind);
|
|
Assert.Single(handler.Requests);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(HttpStatusCode.NotFound)]
|
|
[InlineData(HttpStatusCode.Gone)]
|
|
public async Task MissingOrGoneSource_IsAnItemLevelFailure(HttpStatusCode status)
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
var handler = new RecordingMediaHandler(_ => new HttpResponseMessage(status));
|
|
using var factory = new StubHttpClientFactory(handler);
|
|
var service = new DouyinHttpClientService(factory, null);
|
|
|
|
var result = await service.DownloadAsync("https://www.douyin.com/media/one",
|
|
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret");
|
|
|
|
Assert.Equal(MediaDownloadFailureKind.SourceNotFound, result.FailureKind);
|
|
Assert.Equal((int)status, result.HttpStatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RateLimit_HonorsRetryAfterOnceThenReturnsStructuredCooldown()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
var handler = new RecordingMediaHandler(_ =>
|
|
{
|
|
var response = new HttpResponseMessage((HttpStatusCode)429);
|
|
response.Headers.RetryAfter = new RetryConditionHeaderValue(DateTimeOffset.UtcNow);
|
|
return response;
|
|
});
|
|
using var factory = new StubHttpClientFactory(handler);
|
|
var service = new DouyinHttpClientService(factory, null);
|
|
|
|
var result = await service.DownloadAsync("https://www.douyin.com/media/one",
|
|
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret");
|
|
|
|
Assert.Equal(MediaDownloadFailureKind.SourceRateLimited, result.FailureKind);
|
|
Assert.Equal(2, handler.Requests.Count);
|
|
Assert.NotNull(result.RetryAfter);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MediaConnectTimeout_RetriesTwiceWithBackoffThenSucceeds()
|
|
{
|
|
using var temporary = new TemporaryDirectory();
|
|
var requestCount = 0;
|
|
var delays = new List<TimeSpan>();
|
|
var handler = new RecordingMediaHandler(_ =>
|
|
{
|
|
requestCount++;
|
|
if (requestCount <= 2) throw new HttpRequestException("connect timeout");
|
|
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
|
|
};
|
|
response.Content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4");
|
|
return response;
|
|
});
|
|
using var factory = new StubHttpClientFactory(handler);
|
|
var service = new DouyinHttpClientService(factory, null, (delay, _) =>
|
|
{
|
|
delays.Add(delay);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
var result = await service.DownloadAsync("https://www.douyin.com/media/retry",
|
|
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret");
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Equal(3, requestCount);
|
|
Assert.Equal(2, delays.Count);
|
|
Assert.True(delays[1] > delays[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FollowPostConnectTimeout_RetriesBeforeFailingTheAuthorScan()
|
|
{
|
|
var requestCount = 0;
|
|
var handler = new RecordingMediaHandler(_ =>
|
|
{
|
|
requestCount++;
|
|
if (requestCount <= 2) throw new TaskCanceledException("connect timeout");
|
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("{}")
|
|
};
|
|
});
|
|
using var factory = new StubHttpClientFactory(handler, "https://www.douyin.com");
|
|
var service = new DouyinHttpClientService(factory, null, (_, _) => Task.CompletedTask);
|
|
|
|
var result = await service.SyncUpderPostVideos("20", "0", "sec-user", "sessionid=secret");
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(3, requestCount);
|
|
}
|
|
|
|
private sealed record RecordedMediaRequest(string Host, string Cookie);
|
|
|
|
private sealed class RecordingMediaHandler : HttpMessageHandler
|
|
{
|
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _response;
|
|
|
|
public RecordingMediaHandler(Func<HttpRequestMessage, HttpResponseMessage> response) => _response = response;
|
|
|
|
public List<RecordedMediaRequest> Requests { get; } = new();
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
Requests.Add(new RecordedMediaRequest(
|
|
request.RequestUri.Host,
|
|
request.Headers.TryGetValues("Cookie", out var values) ? values.Single() : null));
|
|
return Task.FromResult(_response(request));
|
|
}
|
|
}
|
|
}
|