feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
+471
View File
@@ -0,0 +1,471 @@
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);
}
}
}