Files
douyin/storage/WebDavMediaStorage.cs

472 lines
26 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Xml.Linq;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service;
namespace dy.net.storage
{
public class WebDavMediaStorage : IMediaStorage
{
private static readonly TimeSpan[] DefaultUploadVisibilityRetryDelays =
{
TimeSpan.Zero,
TimeSpan.FromMilliseconds(200),
TimeSpan.FromMilliseconds(400),
TimeSpan.FromMilliseconds(800),
TimeSpan.FromMilliseconds(1600),
TimeSpan.FromMilliseconds(3200)
};
private static readonly TimeSpan[] DefaultFinalVisibilityRetryDelays =
{
TimeSpan.Zero,
TimeSpan.FromMilliseconds(200),
TimeSpan.FromMilliseconds(400),
TimeSpan.FromMilliseconds(800),
TimeSpan.FromMilliseconds(1600),
TimeSpan.FromMilliseconds(3200),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(8),
TimeSpan.FromSeconds(12),
TimeSpan.FromSeconds(15)
};
private static readonly TimeSpan[] DirectoryConflictRetryDelays =
{
TimeSpan.Zero,
TimeSpan.FromMilliseconds(150),
TimeSpan.FromMilliseconds(400),
TimeSpan.FromMilliseconds(900),
TimeSpan.FromMilliseconds(2000),
TimeSpan.FromMilliseconds(4500),
TimeSpan.FromMilliseconds(8000)
};
private readonly IHttpClientFactory _clientFactory;
private readonly WebDavSettingsService _settingsService;
private readonly IReadOnlyList<TimeSpan> _uploadVisibilityRetryDelays;
private readonly IReadOnlyList<TimeSpan> _finalVisibilityRetryDelays;
public WebDavMediaStorage(IHttpClientFactory clientFactory, WebDavSettingsService settingsService)
: this(clientFactory, settingsService, DefaultUploadVisibilityRetryDelays, DefaultFinalVisibilityRetryDelays)
{
}
internal WebDavMediaStorage(
IHttpClientFactory clientFactory,
WebDavSettingsService settingsService,
IReadOnlyList<TimeSpan> uploadVisibilityRetryDelays,
IReadOnlyList<TimeSpan> finalVisibilityRetryDelays)
{
_clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory));
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_uploadVisibilityRetryDelays = uploadVisibilityRetryDelays?.Count > 0
? uploadVisibilityRetryDelays
: throw new ArgumentException("上传校验重试间隔不能为空", nameof(uploadVisibilityRetryDelays));
_finalVisibilityRetryDelays = finalVisibilityRetryDelays?.Count > 0
? finalVisibilityRetryDelays
: throw new ArgumentException("MOVE 校验重试间隔不能为空", nameof(finalVisibilityRetryDelays));
}
public StorageType StorageType => StorageType.WebDav;
public async Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default) =>
await EnsureDirectoryAsync(path, await _settingsService.GetAsync(), cancellationToken);
public async Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default) =>
!string.IsNullOrWhiteSpace(path) && await ExistsAsync(path, await _settingsService.GetAsync(), cancellationToken);
public async Task<long?> GetLengthAsync(string path, CancellationToken cancellationToken = default) =>
string.IsNullOrWhiteSpace(path) ? null : await GetLengthAsync(path, await _settingsService.GetAsync(), cancellationToken);
public async Task WriteAsync(string path, Stream source, long? contentLength = null, string contentType = null, CancellationToken cancellationToken = default) =>
await WriteAsync(path, source, contentLength, contentType, await _settingsService.GetAsync(), cancellationToken);
public async Task<StorageReadResult> OpenReadAsync(string path, long? from = null, long? to = null, CancellationToken cancellationToken = default) =>
await OpenReadAsync(path, from, to, await _settingsService.GetAsync(), cancellationToken);
public async Task DeleteAsync(string path, CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(path))
await DeleteAsync(path, await _settingsService.GetAsync(), cancellationToken);
}
public IMediaStorage Bind(WebDavSettings settings)
{
Validate(settings);
return new BoundWebDavMediaStorage(this, settings);
}
public async Task<(bool Success, string Message)> ProbeAsync(WebDavSettings settings, CancellationToken cancellationToken = default)
{
Validate(settings);
var probeDirectory = $"/.dysync-probe-{Guid.NewGuid():N}";
var sourcePath = StoragePath.CombineRemote(probeDirectory, "source.txt");
var movedPath = StoragePath.CombineRemote(probeDirectory, "moved.txt");
var bytes = Encoding.UTF8.GetBytes("dysync-webdav-probe");
try
{
await EnsureDirectoryAsync(probeDirectory, settings, cancellationToken);
await using (var source = new MemoryStream(bytes, false))
await WriteAsync(sourcePath, source, bytes.Length, "text/plain", settings, cancellationToken);
var length = await GetLengthAsync(sourcePath, settings, cancellationToken);
if (length != bytes.Length) return (false, $"上传长度校验失败,期望 {bytes.Length},实际 {length?.ToString() ?? "未知"}");
await MoveAsync(sourcePath, movedPath, settings, cancellationToken);
await using (var read = await OpenReadAsync(movedPath, 0, 0, settings, cancellationToken))
{
if (read.StatusCode != (int)HttpStatusCode.PartialContent
|| !ContentRangeHeaderValue.TryParse(read.ContentRange, out var contentRange)
|| !contentRange.HasRange
|| contentRange.Unit != "bytes"
|| contentRange.From != 0
|| contentRange.To != 0
|| !contentRange.HasLength
|| contentRange.Length < 1)
return (false, "服务不支持媒体播放所需的标准 Range 读取(需要 206 和有效 Content-Range");
}
await DeleteAsync(movedPath, settings, cancellationToken);
if (await ExistsAsync(movedPath, settings, cancellationToken))
return (false, "服务未能删除能力探测文件");
return (true, "AList/OpenList WebDAV 读写、移动、Range 与删除能力正常");
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "WebDAV 能力探测失败");
return (false, ex.Message);
}
finally
{
await TryDeleteAsync(sourcePath, settings, CancellationToken.None);
await TryDeleteAsync(movedPath, settings, CancellationToken.None);
await TryDeleteAsync(probeDirectory, settings, CancellationToken.None);
}
}
private async Task EnsureDirectoryAsync(string path, WebDavSettings settings, CancellationToken cancellationToken)
{
Validate(settings);
var combined = CombinedPath(settings, path);
var segments = combined.Split('/', StringSplitOptions.RemoveEmptyEntries);
var current = string.Empty;
foreach (var segment in segments)
{
current = StoragePath.CombineRemote(current, segment);
await EnsureDirectorySegmentAsync(current, settings, cancellationToken);
}
}
private async Task EnsureDirectorySegmentAsync(
string path,
WebDavSettings settings,
CancellationToken cancellationToken)
{
HttpStatusCode lastStatus = 0;
foreach (var delay in DirectoryConflictRetryDelays)
{
if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken);
using var request = await CreateRequestAsync(new HttpMethod("MKCOL"), path, settings);
using var response = await SendAsync(
request,
settings,
HttpCompletionOption.ResponseContentRead,
cancellationToken);
lastStatus = response.StatusCode;
if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.MethodNotAllowed) return;
if (response.StatusCode != HttpStatusCode.Conflict) break;
// AList/OpenList and some mounted storage drivers may return 409 when
// another request has just created the same collection, or while a
// newly-created parent is becoming visible. Only reuse the path after
// PROPFIND confirms that it is a collection; otherwise retry briefly.
if (await DirectoryExistsAsync(path, settings, cancellationToken)) return;
}
throw new HttpRequestException($"创建 WebDAV 目录失败:{path} ({(int)lastStatus})");
}
private async Task<bool> DirectoryExistsAsync(
string combinedPath,
WebDavSettings settings,
CancellationToken cancellationToken)
{
using var request = await CreateRequestAsync(new HttpMethod("PROPFIND"), combinedPath, settings);
request.Headers.TryAddWithoutValidation("Depth", "0");
request.Content = new StringContent(
"<?xml version=\"1.0\"?><propfind xmlns=\"DAV:\"><prop><resourcetype/></prop></propfind>",
Encoding.UTF8,
"application/xml");
using var response = await SendAsync(
request,
settings,
HttpCompletionOption.ResponseContentRead,
cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return false;
if (!response.IsSuccessStatusCode && (int)response.StatusCode != 207) return false;
try
{
var xml = XDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
return xml.Descendants().Any(x => x.Name.LocalName == "collection");
}
catch
{
return false;
}
}
private async Task<bool> ExistsAsync(string path, WebDavSettings settings, CancellationToken cancellationToken)
{
Validate(settings);
using var request = await CreateRequestAsync(new HttpMethod("PROPFIND"), CombinedPath(settings, path), settings);
request.Headers.TryAddWithoutValidation("Depth", "0");
using var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return false;
return response.IsSuccessStatusCode || (int)response.StatusCode == 207;
}
private async Task<long?> GetLengthAsync(string path, WebDavSettings settings, CancellationToken cancellationToken)
{
Validate(settings);
using (var head = await CreateRequestAsync(HttpMethod.Head, CombinedPath(settings, path), settings))
{
AddNoCacheHeaders(head);
using var response = await SendAsync(head, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
if (response.IsSuccessStatusCode && response.Content.Headers.ContentLength.HasValue)
return response.Content.Headers.ContentLength.Value;
}
using var request = await CreateRequestAsync(new HttpMethod("PROPFIND"), CombinedPath(settings, path), settings);
AddNoCacheHeaders(request);
request.Headers.TryAddWithoutValidation("Depth", "0");
request.Content = new StringContent("<?xml version=\"1.0\"?><propfind xmlns=\"DAV:\"><prop><getcontentlength/></prop></propfind>", Encoding.UTF8, "application/xml");
using var propResponse = await SendAsync(request, settings, HttpCompletionOption.ResponseContentRead, cancellationToken);
if (propResponse.StatusCode == HttpStatusCode.NotFound) return null;
propResponse.EnsureSuccessStatusCode();
var xml = XDocument.Parse(await propResponse.Content.ReadAsStringAsync(cancellationToken));
var value = xml.Descendants().FirstOrDefault(x => x.Name.LocalName == "getcontentlength")?.Value;
return long.TryParse(value, out var length) ? length : null;
}
private async Task WriteAsync(string path, Stream source, long? contentLength, string contentType, WebDavSettings settings, CancellationToken cancellationToken)
{
Validate(settings);
path = StoragePath.NormalizeRemote(path);
if (path == "/") throw new ArgumentException("WebDAV 文件路径不能为空", nameof(path));
await EnsureDirectoryAsync(StoragePath.DirectoryName(path), settings, cancellationToken);
var temporaryPath = path + ".part-" + Guid.NewGuid().ToString("N");
try
{
using var put = await CreateRequestAsync(HttpMethod.Put, CombinedPath(settings, temporaryPath), settings);
put.Content = new StreamContent(source, 81920);
if (contentLength.HasValue) put.Content.Headers.ContentLength = contentLength;
if (!string.IsNullOrWhiteSpace(contentType)) put.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
using var response = await SendAsync(put, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var actualLength = await WaitForReadableLengthAsync(
temporaryPath, contentLength, settings, _uploadVisibilityRetryDelays, cancellationToken);
if (contentLength.HasValue && actualLength != contentLength)
throw new IOException($"WebDAV 上传长度不一致:期望 {contentLength},实际 {actualLength?.ToString() ?? "未知"}");
if (!actualLength.HasValue || actualLength <= 0)
throw new IOException("WebDAV 上传后的临时文件不存在或为空");
await MoveAsync(temporaryPath, path, settings, cancellationToken);
var expectedFinalLength = contentLength ?? actualLength;
var finalLength = await WaitForReadableLengthAsync(
path, expectedFinalLength, settings, _finalVisibilityRetryDelays, cancellationToken);
if (!finalLength.HasValue || finalLength <= 0)
throw new IOException($"WebDAV MOVE 后等待约 {TotalDelaySeconds(_finalVisibilityRetryDelays):0.#} 秒,最终文件仍不存在或为空");
if (expectedFinalLength.HasValue && expectedFinalLength > 0 && finalLength != expectedFinalLength)
throw new IOException($"WebDAV MOVE 后等待约 {TotalDelaySeconds(_finalVisibilityRetryDelays):0.#} 秒,最终文件仍返回旧长度或长度不一致:期望 {expectedFinalLength},实际 {finalLength}");
}
finally
{
await TryDeleteAsync(temporaryPath, settings, CancellationToken.None);
}
}
private async Task MoveAsync(string sourcePath, string destinationPath, WebDavSettings settings, CancellationToken cancellationToken)
{
using var request = await CreateRequestAsync(new HttpMethod("MOVE"), CombinedPath(settings, sourcePath), settings);
request.Headers.TryAddWithoutValidation("Destination", BuildUri(settings, CombinedPath(settings, destinationPath)).AbsoluteUri);
request.Headers.TryAddWithoutValidation("Overwrite", "T");
using var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
}
private async Task<long?> WaitForReadableLengthAsync(
string path,
long? expectedLength,
WebDavSettings settings,
IReadOnlyList<TimeSpan> retryDelays,
CancellationToken cancellationToken)
{
long? lastLength = null;
for (var attempt = 0; attempt < retryDelays.Count; attempt++)
{
var delay = retryDelays[attempt];
if (delay > TimeSpan.Zero) await Task.Delay(delay, cancellationToken);
lastLength = await GetLengthAsync(path, settings, cancellationToken);
if (IsUsableLength(lastLength, expectedLength)) return lastLength;
// Some AList/OpenList storage drivers acknowledge MOVE before their metadata
// cache can answer HEAD/PROPFIND. A one-byte ranged read checks the actual object
// without downloading the whole media and also yields its total length.
var readableLength = await TryGetLengthFromRangeAsync(path, settings, cancellationToken);
if (IsUsableLength(readableLength, expectedLength))
{
if (attempt > 0 || !lastLength.HasValue)
Serilog.Log.Debug("WebDAV 元数据延迟,已通过 Range 读取确认文件:{Path}", path);
return readableLength;
}
if (readableLength.HasValue) lastLength = readableLength;
}
return lastLength;
}
private async Task<long?> TryGetLengthFromRangeAsync(
string path,
WebDavSettings settings,
CancellationToken cancellationToken)
{
using var request = await CreateRequestAsync(HttpMethod.Get, CombinedPath(settings, path), settings);
AddNoCacheHeaders(request);
request.Headers.Range = new RangeHeaderValue(0, 0);
using var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
return response.Content.Headers.ContentRange?.Length ?? 0;
response.EnsureSuccessStatusCode();
if (response.Content.Headers.ContentRange?.Length is long totalLength) return totalLength;
return response.StatusCode == HttpStatusCode.OK
? response.Content.Headers.ContentLength
: null;
}
private static bool IsUsableLength(long? actualLength, long? expectedLength) =>
actualLength > 0 && (!expectedLength.HasValue || expectedLength.Value <= 0 || actualLength == expectedLength);
private static void AddNoCacheHeaders(HttpRequestMessage request)
{
request.Headers.CacheControl = new CacheControlHeaderValue
{
NoCache = true,
NoStore = true,
MaxAge = TimeSpan.Zero
};
request.Headers.Pragma.ParseAdd("no-cache");
}
private static double TotalDelaySeconds(IEnumerable<TimeSpan> delays) =>
delays.Sum(delay => delay.TotalSeconds);
private async Task<StorageReadResult> OpenReadAsync(string path, long? from, long? to, WebDavSettings settings, CancellationToken cancellationToken)
{
Validate(settings);
var request = await CreateRequestAsync(HttpMethod.Get, CombinedPath(settings, path), settings);
if (from.HasValue) request.Headers.Range = new RangeHeaderValue(from, to);
var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
request.Dispose();
if (response.StatusCode == HttpStatusCode.NotFound)
{
response.Dispose();
throw new FileNotFoundException("WebDAV 媒体文件不存在", path);
}
response.EnsureSuccessStatusCode();
return new StorageReadResult
{
Stream = await response.Content.ReadAsStreamAsync(cancellationToken),
ContentLength = response.Content.Headers.ContentLength,
ContentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream",
ContentRange = response.Content.Headers.ContentRange?.ToString(),
StatusCode = (int)response.StatusCode,
Owner = new HttpResponseOwner(response)
};
}
private async Task DeleteAsync(string path, WebDavSettings settings, CancellationToken cancellationToken)
{
Validate(settings);
using var request = await CreateRequestAsync(HttpMethod.Delete, CombinedPath(settings, path), settings);
using var response = await SendAsync(request, settings, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (response.StatusCode != HttpStatusCode.NotFound) response.EnsureSuccessStatusCode();
}
private async Task TryDeleteAsync(string path, WebDavSettings settings, CancellationToken cancellationToken)
{
try { await DeleteAsync(path, settings, cancellationToken); }
catch (Exception ex) { Serilog.Log.Debug(ex, "清理 WebDAV 临时路径失败:{Path}", path); }
}
private async Task<HttpRequestMessage> CreateRequestAsync(HttpMethod method, string path, WebDavSettings settings)
{
var request = new HttpRequestMessage(method, BuildUri(settings, path));
var password = await _settingsService.GetPasswordAsync(settings);
var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{settings.UserName}:{password}"));
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", token);
return request;
}
private async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, WebDavSettings settings, HttpCompletionOption option, CancellationToken cancellationToken)
{
var client = _clientFactory.CreateClient(settings.AllowInvalidCertificate ? "webdav-insecure" : "webdav");
return await client.SendAsync(request, option, cancellationToken);
}
private static string CombinedPath(WebDavSettings settings, string path) =>
StoragePath.CombineRemote(settings.BasePath, path);
private static Uri BuildUri(WebDavSettings settings, string combinedPath)
{
var endpoint = settings.Endpoint.TrimEnd('/');
return new Uri(endpoint + StoragePath.Encode(combinedPath), UriKind.Absolute);
}
private static void Validate(WebDavSettings settings)
{
if (settings == null) throw new InvalidOperationException("尚未配置 WebDAV");
if (!Uri.TryCreate(settings.Endpoint, UriKind.Absolute, out var endpoint) || (endpoint.Scheme != "http" && endpoint.Scheme != "https"))
throw new InvalidOperationException("WebDAV 地址必须是有效的 HTTP/HTTPS 地址,例如 http://host:5244/dav");
if (string.IsNullOrWhiteSpace(settings.UserName)) throw new InvalidOperationException("WebDAV 用户名不能为空");
StoragePath.NormalizeRemote(settings.BasePath);
}
private sealed class BoundWebDavMediaStorage : IMediaStorage
{
private readonly WebDavMediaStorage _owner;
private readonly WebDavSettings _settings;
public BoundWebDavMediaStorage(WebDavMediaStorage owner, WebDavSettings settings)
{
_owner = owner;
_settings = settings;
}
public StorageType StorageType => StorageType.WebDav;
public Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default) =>
_owner.EnsureDirectoryAsync(path, _settings, cancellationToken);
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default) =>
string.IsNullOrWhiteSpace(path) ? Task.FromResult(false) : _owner.ExistsAsync(path, _settings, cancellationToken);
public Task<long?> GetLengthAsync(string path, CancellationToken cancellationToken = default) =>
string.IsNullOrWhiteSpace(path) ? Task.FromResult<long?>(null) : _owner.GetLengthAsync(path, _settings, cancellationToken);
public Task WriteAsync(string path, Stream source, long? contentLength = null, string contentType = null, CancellationToken cancellationToken = default) =>
_owner.WriteAsync(path, source, contentLength, contentType, _settings, cancellationToken);
public Task<StorageReadResult> OpenReadAsync(string path, long? from = null, long? to = null, CancellationToken cancellationToken = default) =>
_owner.OpenReadAsync(path, from, to, _settings, cancellationToken);
public Task DeleteAsync(string path, CancellationToken cancellationToken = default) =>
string.IsNullOrWhiteSpace(path) ? Task.CompletedTask : _owner.DeleteAsync(path, _settings, cancellationToken);
}
}
}