feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user