Files
douyin/tests/dy.net.Tests/MediaDownloadTests.cs
T

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));
}
}
}