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
+15
View File
@@ -0,0 +1,15 @@
namespace dy.net.storage
{
internal sealed class HttpResponseOwner : IAsyncDisposable
{
private readonly HttpResponseMessage _response;
public HttpResponseOwner(HttpResponseMessage response) => _response = response;
public ValueTask DisposeAsync()
{
_response.Dispose();
return ValueTask.CompletedTask;
}
}
}
+27
View File
@@ -0,0 +1,27 @@
using dy.net.model.dto;
namespace dy.net.storage
{
public interface IMediaStorage
{
StorageType StorageType { get; }
Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default);
Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default);
Task<long?> GetLengthAsync(string path, CancellationToken cancellationToken = default);
Task WriteAsync(string path, Stream source, long? contentLength = null, string contentType = null, CancellationToken cancellationToken = default);
Task<StorageReadResult> OpenReadAsync(string path, long? from = null, long? to = null, CancellationToken cancellationToken = default);
Task DeleteAsync(string path, CancellationToken cancellationToken = default);
}
/// <summary>
/// Optional capability for providers that expose a canonical path spelling different from
/// the logical path requested by the application.
/// </summary>
public interface ICanonicalMediaStorage
{
Task<string> CanonicalizePathAsync(
string path,
bool createParentDirectories = false,
CancellationToken cancellationToken = default);
}
}
+82
View File
@@ -0,0 +1,82 @@
using dy.net.model.dto;
namespace dy.net.storage
{
public class LocalMediaStorage : IMediaStorage
{
public StorageType StorageType => StorageType.Local;
public Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(path)) Directory.CreateDirectory(path);
return Task.CompletedTask;
}
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default) =>
Task.FromResult(File.Exists(path));
public Task<long?> GetLengthAsync(string path, CancellationToken cancellationToken = default) =>
Task.FromResult<long?>(File.Exists(path) ? new FileInfo(path).Length : null);
public async Task WriteAsync(string path, Stream source, long? contentLength = null, string contentType = null, CancellationToken cancellationToken = default)
{
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory);
var tempPath = path + ".part-" + Guid.NewGuid().ToString("N");
try
{
await using (var output = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, true))
{
await source.CopyToAsync(output, 81920, cancellationToken);
await output.FlushAsync(cancellationToken);
}
var actualLength = new FileInfo(tempPath).Length;
if (actualLength <= 0) throw new IOException("本地写入后的临时文件为空");
if (contentLength.HasValue && actualLength != contentLength.Value)
throw new IOException($"本地写入长度不一致:期望 {contentLength.Value},实际 {actualLength}");
File.Move(tempPath, path, true);
var finalLength = new FileInfo(path).Length;
if (finalLength != actualLength) throw new IOException($"本地最终文件长度不一致:期望 {actualLength},实际 {finalLength}");
}
finally
{
if (File.Exists(tempPath)) File.Delete(tempPath);
}
}
public Task<StorageReadResult> OpenReadAsync(string path, long? from = null, long? to = null, CancellationToken cancellationToken = default)
{
if (!File.Exists(path)) throw new FileNotFoundException("媒体文件不存在", path);
var file = new FileInfo(path);
var start = Math.Max(0, from ?? 0);
var end = Math.Min(file.Length - 1, to ?? file.Length - 1);
var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true);
stream.Seek(start, SeekOrigin.Begin);
return Task.FromResult(new StorageReadResult
{
Stream = stream,
ContentLength = end - start + 1,
ContentType = MimeType(path),
ContentRange = from.HasValue ? $"bytes {start}-{end}/{file.Length}" : null,
StatusCode = from.HasValue ? 206 : 200
});
}
public Task DeleteAsync(string path, CancellationToken cancellationToken = default)
{
if (File.Exists(path)) File.Delete(path);
return Task.CompletedTask;
}
private static string MimeType(string path) => Path.GetExtension(path).ToLowerInvariant() switch
{
".mp4" => "video/mp4",
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".mp3" => "audio/mpeg",
".nfo" or ".xml" => "application/xml",
_ => "application/octet-stream"
};
}
}
+25
View File
@@ -0,0 +1,25 @@
using dy.net.model.dto;
namespace dy.net.storage
{
public class MediaStorageRouter
{
private readonly LocalMediaStorage _local;
private readonly WebDavMediaStorage _webDav;
private readonly OpenListMediaStorage _openList;
public MediaStorageRouter(LocalMediaStorage local, WebDavMediaStorage webDav, OpenListMediaStorage openList)
{
_local = local;
_webDav = webDav;
_openList = openList;
}
public IMediaStorage Resolve(StorageType storageType) => storageType switch
{
StorageType.WebDav => _webDav,
StorageType.OpenList => _openList,
_ => _local
};
}
}
+887
View File
@@ -0,0 +1,887 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using dy.net.model.dto;
using dy.net.model.entity;
namespace dy.net.storage
{
/// <summary>
/// OpenList 原生 API 客户端。所有需要认证的请求共用 token 缓存,401 时只重新登录一次。
/// </summary>
public sealed class OpenListClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan CanonicalPathCacheLifetime = TimeSpan.FromMinutes(30);
private static readonly TimeSpan[] CreatedDirectoryVisibilityDelays =
{
TimeSpan.Zero,
TimeSpan.FromMilliseconds(500),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(4)
};
private readonly IHttpClientFactory _httpClientFactory;
private readonly Func<TimeSpan, CancellationToken, Task> _delayAsync;
private readonly ConcurrentDictionary<string, TokenCacheEntry> _tokens = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CanonicalPathCacheEntry> _canonicalDirectories = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _directoryGates = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _loginGate = new(1, 1);
public OpenListClient(IHttpClientFactory httpClientFactory)
: this(httpClientFactory, Task.Delay)
{
}
internal OpenListClient(
IHttpClientFactory httpClientFactory,
Func<TimeSpan, CancellationToken, Task> delayAsync)
{
_httpClientFactory = httpClientFactory;
_delayAsync = delayAsync ?? Task.Delay;
}
public async Task<string> ProbeAsync(
OpenListSettings settings,
string password,
CancellationToken cancellationToken = default)
{
var connection = Connect(settings, password);
_ = await GetTokenAsync(connection, true, cancellationToken);
using var client = _httpClientFactory.CreateClient("openlist");
using var response = await client.GetAsync($"{connection.BaseUrl}/api/public/settings", cancellationToken);
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
EnsureSuccess(envelope, "OpenList connection test");
var version = envelope.Data is { ValueKind: JsonValueKind.Object } data
&& data.TryGetProperty("version", out var versionElement)
? versionElement.GetString()
: null;
return string.IsNullOrWhiteSpace(version)
? "OpenList 连接和登录成功。"
: $"OpenList 连接和登录成功:{version}";
}
public async Task<OpenListDirectoryListDto> ListDirectoriesAsync(
OpenListSettings settings,
string password,
string path,
bool refresh,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
path = await ResolveDirectoryCoreAsync(connection, path, false, refresh, cancellationToken);
var directory = await ListDirectoryEntriesCoreAsync(connection, path, refresh, cancellationToken);
var result = new OpenListDirectoryListDto { Path = path, CanWrite = directory.CanWrite };
foreach (var item in directory.Entries.Where(x => x.IsDirectory))
result.Directories.Add(new OpenListDirectoryItemDto { Name = item.Name, Path = StoragePath.CombineRemote(path, item.Name) });
result.Directories = result.Directories.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase).ToList();
return result;
}
public async Task<OpenListDirectoryInspection> InspectDirectoryAsync(
OpenListSettings settings,
string password,
string path,
bool refresh = true,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
var canonical = await ResolveDirectoryCoreAsync(connection, path, false, refresh, cancellationToken);
try
{
var listing = await ListDirectoryEntriesCoreAsync(connection, canonical, refresh, cancellationToken);
return new OpenListDirectoryInspection(canonical, true, listing.Entries.Count,
listing.Entries.Count(x => x.IsDirectory), listing.Entries.Count(x => !x.IsDirectory));
}
catch (Exception ex) when (IsMissingDirectoryError(ex))
{
return new OpenListDirectoryInspection(canonical, false, 0, 0, 0);
}
}
/// <summary>
/// Inspects a directory whose exact server-side path came from a fresh parent listing.
/// This deliberately skips segment-by-segment canonical resolution, which would otherwise
/// refresh a very large parent once for every repair candidate.
/// </summary>
public async Task<OpenListDirectoryInspection> InspectKnownDirectoryAsync(
OpenListSettings settings,
string password,
string exactPath,
bool refresh = true,
CancellationToken cancellationToken = default)
{
exactPath = StoragePath.NormalizeRemote(exactPath);
var connection = Connect(settings, password);
try
{
var listing = await ListDirectoryEntriesCoreAsync(connection, exactPath, refresh, cancellationToken);
return new OpenListDirectoryInspection(exactPath, true, listing.Entries.Count,
listing.Entries.Count(x => x.IsDirectory), listing.Entries.Count(x => !x.IsDirectory));
}
catch (Exception ex) when (IsMissingDirectoryError(ex))
{
return new OpenListDirectoryInspection(exactPath, false, 0, 0, 0);
}
}
public async Task<string> EnsureDirectoryAsync(
OpenListSettings settings,
string password,
string path,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
return await ResolveDirectoryCoreAsync(connection, path, true, false, cancellationToken);
}
/// <summary>
/// Resolves every existing directory segment through the parent's listing and returns the
/// server-side spelling. This avoids fs/get inconsistencies on case-insensitive cloud
/// drivers (for example a logical Kk path whose real directory is KK).
/// </summary>
public async Task<string> ResolveCanonicalObjectPathAsync(
OpenListSettings settings,
string password,
string path,
bool createParentDirectories = false,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
if (path == "/") return path;
var connection = Connect(settings, password);
var parent = await ResolveDirectoryCoreAsync(connection, StoragePath.DirectoryName(path),
createParentDirectories, false, cancellationToken);
var requestedName = GetFileName(path);
OpenListDirectorySnapshot entries;
try
{
entries = await ListDirectoryEntriesCoreAsync(connection, parent, false, cancellationToken);
}
catch (Exception ex) when (!createParentDirectories && IsMissingDirectoryError(ex))
{
// A new media path normally contains a per-video directory that does not exist
// until the transfer starts. Canonicalize the existing prefix, then preserve the
// missing tail instead of treating OpenList's object-not-found response as a
// storage failure before the first download attempt.
return StoragePath.CombineRemote(parent, requestedName);
}
var existing = SelectCanonicalEntry(entries.Entries, requestedName, null, path);
return StoragePath.CombineRemote(parent, existing?.Name ?? requestedName);
}
public async Task<OpenListObjectInfo?> TryGetObjectAsync(
OpenListSettings settings,
string password,
string path,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
var canonical = await ResolveObjectPathCoreAsync(connection, path, false, cancellationToken);
var result = await TryGetObjectCoreAsync(connection, canonical, cancellationToken);
if (result != null || path == "/") return result;
InvalidateCanonicalDirectory(connection, StoragePath.DirectoryName(path));
canonical = await ResolveObjectPathCoreAsync(connection, path, true, cancellationToken);
if (!await TryRefreshDirectoryAsync(connection, StoragePath.DirectoryName(canonical), cancellationToken)) return null;
return await TryGetObjectCoreAsync(connection, canonical, cancellationToken);
}
public async Task DeleteObjectAsync(
OpenListSettings settings,
string password,
string path,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
if (path == "/") throw new InvalidOperationException("拒绝删除 OpenList 根目录。");
var connection = Connect(settings, password);
path = await ResolveObjectPathCoreAsync(connection, path, true, cancellationToken);
var envelope = await SendAuthorizedAsync(
connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/remove", new
{
names = new[] { GetFileName(path) },
dir = StoragePath.DirectoryName(path)
}), cancellationToken);
if (IsMissing(envelope)) return;
EnsureSuccess(envelope, $"OpenList remove '{path}'");
}
public async Task DeleteKnownObjectAsync(
OpenListSettings settings,
string password,
string exactPath,
CancellationToken cancellationToken = default)
{
exactPath = StoragePath.NormalizeRemote(exactPath);
if (exactPath == "/") throw new InvalidOperationException("拒绝删除 OpenList 根目录。");
var connection = Connect(settings, password);
var envelope = await SendAuthorizedAsync(
connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/remove", new
{
names = new[] { GetFileName(exactPath) },
dir = StoragePath.DirectoryName(exactPath)
}), cancellationToken);
if (IsMissing(envelope)) return;
EnsureSuccess(envelope, $"OpenList remove known object '{exactPath}'");
}
public async Task<OpenListCopyResult> CopyFileAsync(
OpenListSettings settings,
string password,
string sourcePath,
string targetPath,
CancellationToken cancellationToken = default)
{
sourcePath = StoragePath.NormalizeRemote(sourcePath);
targetPath = StoragePath.NormalizeRemote(targetPath);
var connection = Connect(settings, password);
sourcePath = await ResolveObjectPathCoreAsync(connection, sourcePath, false, cancellationToken);
var targetParent = await ResolveDirectoryCoreAsync(connection, StoragePath.DirectoryName(targetPath), true, false, cancellationToken);
targetPath = StoragePath.CombineRemote(targetParent, GetFileName(targetPath));
if (!string.Equals(GetFileName(sourcePath), GetFileName(targetPath), StringComparison.Ordinal))
throw new InvalidOperationException("OpenList 服务端复制要求源文件名与暂存文件名一致。");
var envelope = await SendAuthorizedAsync(
connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/copy", new
{
src_dir = StoragePath.DirectoryName(sourcePath),
dst_dir = StoragePath.DirectoryName(targetPath),
names = new[] { GetFileName(sourcePath) },
overwrite = false,
skip_existing = false,
merge = false
}), cancellationToken);
EnsureSuccess(envelope, $"OpenList copy '{sourcePath}' to '{targetPath}'");
var ids = new List<string>();
if (envelope.Data is { ValueKind: JsonValueKind.Object } data
&& data.TryGetProperty("tasks", out var tasks)
&& tasks.ValueKind == JsonValueKind.Array)
{
foreach (var task in tasks.EnumerateArray())
{
var id = task.TryGetProperty("id", out var idElement) ? idElement.GetString() : null;
if (!string.IsNullOrWhiteSpace(id)) ids.Add(id);
}
}
return new OpenListCopyResult(ids);
}
public async Task<OpenListTaskInfo?> TryGetCopyTaskAsync(
OpenListSettings settings,
string password,
string taskId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(taskId)) return null;
var encoded = Uri.EscapeDataString(taskId.Trim());
var envelope = await SendAuthorizedAsync(Connect(settings, password),
() => new HttpRequestMessage(HttpMethod.Post, $"/api/task/copy/info?tid={encoded}"),
cancellationToken);
if (IsMissing(envelope)) return null;
EnsureSuccess(envelope, $"OpenList copy task '{taskId}'");
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data) return null;
return new OpenListTaskInfo(
data.TryGetProperty("id", out var id) ? id.GetString() ?? taskId : taskId,
data.TryGetProperty("state", out var state) && state.TryGetInt32(out var stateValue) ? stateValue : -1,
data.TryGetProperty("progress", out var progress) && progress.TryGetDouble(out var progressValue) ? progressValue : 0,
data.TryGetProperty("status", out var status) ? status.GetString() ?? string.Empty : string.Empty,
data.TryGetProperty("error", out var error) ? error.GetString() : null);
}
public async Task<bool> TryCancelCopyTaskAsync(
OpenListSettings settings,
string password,
string taskId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(taskId)) return true;
var encoded = Uri.EscapeDataString(taskId.Trim());
var envelope = await SendAuthorizedAsync(Connect(settings, password),
() => new HttpRequestMessage(HttpMethod.Post, $"/api/task/copy/cancel?tid={encoded}"),
cancellationToken);
return envelope.Code == 200 || IsMissing(envelope) || ContainsAny(envelope.Message, "finished");
}
public async Task RenameAsync(
OpenListSettings settings,
string password,
string path,
string newName,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
if (string.IsNullOrWhiteSpace(newName) || newName.Contains('/') || newName.Contains('\\'))
throw new InvalidOperationException("OpenList 新文件名无效。");
var connection = Connect(settings, password);
path = await ResolveObjectPathCoreAsync(connection, path, true, cancellationToken);
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/rename", new { path, name = newName.Trim() }),
cancellationToken);
EnsureSuccess(envelope, $"OpenList rename '{path}'");
}
public async Task MoveAsync(
OpenListSettings settings,
string password,
string sourcePath,
string targetDirectory,
bool overwrite,
CancellationToken cancellationToken = default)
{
sourcePath = StoragePath.NormalizeRemote(sourcePath);
targetDirectory = StoragePath.NormalizeRemote(targetDirectory);
var connection = Connect(settings, password);
sourcePath = await ResolveObjectPathCoreAsync(connection, sourcePath, true, cancellationToken);
targetDirectory = await ResolveDirectoryCoreAsync(connection, targetDirectory, true, false, cancellationToken);
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/move", new
{
src_dir = StoragePath.DirectoryName(sourcePath),
dst_dir = targetDirectory,
names = new[] { GetFileName(sourcePath) },
overwrite
}), cancellationToken);
EnsureSuccess(envelope, $"OpenList move '{sourcePath}' to '{targetDirectory}'");
}
public async Task<StorageReadResult> OpenReadAsync(
OpenListSettings settings,
string password,
string path,
long? from,
long? to,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
path = await ResolveObjectPathCoreAsync(connection, path, true, cancellationToken);
var info = await TryGetObjectCoreAsync(connection, path, cancellationToken)
?? throw new FileNotFoundException("OpenList 媒体文件不存在", path);
var rawUrl = BuildRawUrl(connection.BaseUrl, path, info.RawUrl);
// OpenList can return a signed URL hosted by the underlying cloud driver.
// Its login token must never be forwarded to a different origin; some
// object-storage providers also reject that unrelated Authorization header.
var authorizeRawRequest = IsSameOrigin(connection.BaseUrl, rawUrl);
var attemptCount = authorizeRawRequest ? 2 : 1;
for (var attempt = 0; attempt < attemptCount; attempt++)
{
var client = _httpClientFactory.CreateClient("openlist");
using var request = new HttpRequestMessage(HttpMethod.Get, rawUrl);
if (authorizeRawRequest)
{
var token = await GetTokenAsync(connection, attempt > 0, cancellationToken);
request.Headers.TryAddWithoutValidation("Authorization", token);
}
if (from.HasValue) request.Headers.Range = new RangeHeaderValue(from, to);
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (authorizeRawRequest && response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
InvalidateToken(connection);
continue;
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
response.Dispose();
throw new FileNotFoundException("OpenList 媒体文件不存在", 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)
};
}
throw new InvalidOperationException("OpenList 登录状态无效,请检查账号或密码。");
}
public static string NormalizeBaseUrl(string? baseUrl)
{
if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri)
|| uri.Scheme is not ("http" or "https"))
throw new InvalidOperationException("OpenList 地址必须是有效的 HTTP/HTTPS URL。");
var path = uri.AbsolutePath.TrimEnd('/');
var davIndex = path.IndexOf("/dav", StringComparison.OrdinalIgnoreCase);
if (davIndex >= 0 && (davIndex + 4 == path.Length || path[davIndex + 4] == '/')) path = path[..davIndex];
return new UriBuilder(uri) { Path = path, Query = string.Empty, Fragment = string.Empty }
.Uri.ToString().TrimEnd('/');
}
private async Task<OpenListObjectInfo?> TryGetObjectCoreAsync(
ConnectionInfo connection,
string path,
CancellationToken cancellationToken)
{
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/get", new { path, password = string.Empty }),
cancellationToken);
if (IsMissing(envelope)) return null;
EnsureSuccess(envelope, $"OpenList get '{path}'");
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data) return null;
var hashes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (data.TryGetProperty("hash_info", out var hashInfo) && hashInfo.ValueKind == JsonValueKind.Object)
{
foreach (var property in hashInfo.EnumerateObject())
{
var value = property.Value.GetString();
if (!string.IsNullOrWhiteSpace(value)) hashes[property.Name] = value;
}
}
return new OpenListObjectInfo(
data.TryGetProperty("name", out var name) ? name.GetString() ?? string.Empty : string.Empty,
data.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0,
data.TryGetProperty("is_dir", out var isDirectory) && isDirectory.GetBoolean(),
hashes,
data.TryGetProperty("raw_url", out var rawUrl) ? rawUrl.GetString() : null);
}
private async Task<string> ResolveObjectPathCoreAsync(
ConnectionInfo connection,
string path,
bool forceRefresh,
CancellationToken cancellationToken)
{
path = StoragePath.NormalizeRemote(path);
if (path == "/") return path;
var parent = await ResolveDirectoryCoreAsync(connection, StoragePath.DirectoryName(path), false,
forceRefresh, cancellationToken);
var requestedName = GetFileName(path);
OpenListDirectorySnapshot listing;
try
{
listing = await ListDirectoryEntriesCoreAsync(connection, parent, forceRefresh, cancellationToken);
}
catch (Exception ex) when (IsMissingDirectoryError(ex))
{
// A missing parent is a normal negative lookup for a brand-new media path.
// This must also apply to the forced refresh performed by TryGetObjectAsync;
// otherwise its second pass turns OpenList's code-500 object-not-found response
// into a storage failure before the transfer has had a chance to create the path.
return StoragePath.CombineRemote(parent, requestedName);
}
var entry = SelectCanonicalEntry(listing.Entries, requestedName, null, path);
return StoragePath.CombineRemote(parent, entry?.Name ?? requestedName);
}
private async Task<string> ResolveDirectoryCoreAsync(
ConnectionInfo connection,
string path,
bool createMissing,
bool forceRefresh,
CancellationToken cancellationToken)
{
path = StoragePath.NormalizeRemote(path);
if (path == "/") return path;
var requested = string.Empty;
var actual = string.Empty;
var segments = SplitPath(path);
for (var index = 0; index < segments.Length; index++)
{
var segment = segments[index];
requested = StoragePath.CombineRemote(requested, segment);
var cacheKey = BuildCanonicalCacheKey(connection, requested);
if (!forceRefresh && _canonicalDirectories.TryGetValue(cacheKey, out var cached)
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
{
actual = cached.Path;
continue;
}
var gate = _directoryGates.GetOrAdd(cacheKey, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
try
{
if (!forceRefresh && _canonicalDirectories.TryGetValue(cacheKey, out cached)
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
{
actual = cached.Path;
continue;
}
OpenListDirectorySnapshot parent;
try
{
parent = await ListDirectoryEntriesCoreAsync(connection,
string.IsNullOrWhiteSpace(actual) ? "/" : actual, forceRefresh, cancellationToken);
}
catch (Exception ex) when (!createMissing && IsMissingDirectoryError(ex))
{
return AppendRemaining(actual, segments, index);
}
var match = SelectCanonicalEntry(parent.Entries, segment, true, requested);
if (match == null && !forceRefresh)
{
parent = await ListDirectoryEntriesCoreAsync(connection,
string.IsNullOrWhiteSpace(actual) ? "/" : actual, true, cancellationToken);
match = SelectCanonicalEntry(parent.Entries, segment, true, requested);
}
if (match == null && !createMissing) return AppendRemaining(actual, segments, index);
if (match == null)
{
var parentPath = string.IsNullOrWhiteSpace(actual) ? "/" : actual;
var desired = StoragePath.CombineRemote(parentPath, segment);
ApiEnvelope envelope;
try
{
envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/mkdir", new { path = desired }),
cancellationToken);
}
catch (TimeoutException)
{
var afterTimeout = await ListDirectoryEntriesCoreAsync(connection, parentPath, true, cancellationToken);
match = SelectCanonicalEntry(afterTimeout.Entries, segment, true, requested);
if (match == null) throw;
envelope = new ApiEnvelope(200, "verified after timeout", null);
}
if (envelope.Code != 200 && !ContainsAny(envelope.Message, "exist", "already"))
EnsureSuccess(envelope, $"OpenList mkdir '{desired}'");
match = await WaitForCreatedDirectoryAsync(
connection, parentPath, segment, requested, cancellationToken);
if (match == null)
throw new InvalidOperationException(
$"OpenList 创建目录 '{desired}' 后经多次刷新仍未出现同名目录。底层存储可能自动改名,已停止继续写入以避免产生重复目录。");
}
if (!match.IsDirectory)
throw new InvalidOperationException($"OpenList 路径 '{requested}' 已存在但不是目录。");
actual = StoragePath.CombineRemote(string.IsNullOrWhiteSpace(actual) ? "/" : actual, match.Name);
_canonicalDirectories[cacheKey] = new CanonicalPathCacheEntry(actual,
DateTimeOffset.UtcNow.Add(CanonicalPathCacheLifetime));
}
finally
{
gate.Release();
}
}
return StoragePath.NormalizeRemote(actual);
}
private async Task<OpenListDirectoryEntry?> WaitForCreatedDirectoryAsync(
ConnectionInfo connection,
string parentPath,
string segment,
string requestedPath,
CancellationToken cancellationToken)
{
foreach (var delay in CreatedDirectoryVisibilityDelays)
{
if (delay > TimeSpan.Zero) await _delayAsync(delay, cancellationToken);
try
{
var refreshed = await ListDirectoryEntriesCoreAsync(
connection, parentPath, true, cancellationToken);
var match = SelectCanonicalEntry(refreshed.Entries, segment, true, requestedPath);
if (match != null) return match;
}
catch (Exception ex) when (IsMissingDirectoryError(ex))
{
// Some remote drivers acknowledge mkdir before the refreshed parent becomes
// readable. Keep the retry bounded and cancellation-aware.
}
}
return null;
}
private async Task<OpenListDirectorySnapshot> ListDirectoryEntriesCoreAsync(
ConnectionInfo connection,
string path,
bool refresh,
CancellationToken cancellationToken)
{
path = StoragePath.NormalizeRemote(path);
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/list", new
{
path,
password = string.Empty,
refresh,
page = 1,
per_page = 0
}), cancellationToken, retryTransient: true);
EnsureSuccess(envelope, $"OpenList list '{path}'");
var result = new OpenListDirectorySnapshot();
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data) return result;
result.CanWrite = data.TryGetProperty("write", out var writable) && writable.ValueKind == JsonValueKind.True;
AddEntries(data, "content", result.Entries);
AddEntries(data, "related", result.Entries);
result.Entries = result.Entries
.GroupBy(x => $"{x.Name}\n{x.IsDirectory}", StringComparer.Ordinal)
.Select(x => x.First()).ToList();
return result;
}
private static void AddEntries(JsonElement data, string propertyName, List<OpenListDirectoryEntry> target)
{
if (!data.TryGetProperty(propertyName, out var content) || content.ValueKind != JsonValueKind.Array) return;
foreach (var item in content.EnumerateArray())
{
var name = item.TryGetProperty("name", out var nameElement) ? nameElement.GetString() : null;
if (string.IsNullOrWhiteSpace(name)) continue;
target.Add(new OpenListDirectoryEntry(
name,
item.TryGetProperty("is_dir", out var isDirectory) && isDirectory.GetBoolean(),
item.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0));
}
}
private static OpenListDirectoryEntry? SelectCanonicalEntry(
IReadOnlyList<OpenListDirectoryEntry> entries,
string requestedName,
bool? requireDirectory,
string requestedPath)
{
var candidates = entries.Where(x => !requireDirectory.HasValue || x.IsDirectory == requireDirectory.Value).ToList();
var exact = candidates.Where(x => string.Equals(x.Name, requestedName, StringComparison.Ordinal)).ToList();
if (exact.Count == 1) return exact[0];
if (exact.Count > 1) throw new InvalidOperationException($"OpenList 路径 '{requestedPath}' 返回了多个完全同名对象。");
var insensitive = candidates.Where(x => string.Equals(x.Name, requestedName, StringComparison.OrdinalIgnoreCase)).ToList();
if (insensitive.Count == 1) return insensitive[0];
if (insensitive.Count > 1)
throw new InvalidOperationException(
$"OpenList 路径 '{requestedPath}' 存在多个仅大小写不同的候选:{string.Join("", insensitive.Select(x => x.Name))}。");
return null;
}
private static string AppendRemaining(string actual, IReadOnlyList<string> segments, int startIndex)
{
var result = string.IsNullOrWhiteSpace(actual) ? "/" : actual;
for (var i = startIndex; i < segments.Count; i++) result = StoragePath.CombineRemote(result, segments[i]);
return result;
}
private void InvalidateCanonicalDirectory(ConnectionInfo connection, string requestedDirectory)
{
requestedDirectory = StoragePath.NormalizeRemote(requestedDirectory);
foreach (var key in _canonicalDirectories.Keys.Where(x => x.StartsWith(BuildCacheKey(connection) + "\n", StringComparison.Ordinal)))
{
if (key.EndsWith("\n" + requestedDirectory, StringComparison.Ordinal)
|| key.Contains("\n" + requestedDirectory.TrimEnd('/') + "/", StringComparison.Ordinal))
_canonicalDirectories.TryRemove(key, out _);
}
}
private static string BuildCanonicalCacheKey(ConnectionInfo connection, string requestedPath) =>
BuildCacheKey(connection) + "\n" + StoragePath.NormalizeRemote(requestedPath);
private static bool IsMissingDirectoryError(Exception ex) =>
ContainsAny(ex.GetBaseException().Message, "not found", "object not found", "no such file", "get dir");
private async Task<bool> TryRefreshDirectoryAsync(
ConnectionInfo connection,
string path,
CancellationToken cancellationToken)
{
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/list", new
{
path,
password = string.Empty,
refresh = true,
page = 1,
per_page = 0
}), cancellationToken);
if (envelope.Code == 200) return true;
if (IsMissing(envelope)) return false;
EnsureSuccess(envelope, $"OpenList refresh '{path}'");
return true;
}
private async Task<ApiEnvelope> SendAuthorizedAsync(
ConnectionInfo connection,
Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken,
bool retryTransient = false)
{
for (var transientAttempt = 0; transientAttempt < (retryTransient ? 2 : 1); transientAttempt++)
{
for (var authAttempt = 0; authAttempt < 2; authAttempt++)
{
var token = await GetTokenAsync(connection, authAttempt > 0, cancellationToken);
using var client = _httpClientFactory.CreateClient("openlist");
using var request = requestFactory();
request.RequestUri = new Uri(connection.BaseUrl + request.RequestUri, UriKind.Absolute);
request.Headers.TryAddWithoutValidation("Authorization", token);
try
{
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
if (response.StatusCode != HttpStatusCode.Unauthorized && envelope.Code != 401) return envelope;
InvalidateToken(connection);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
if (retryTransient && transientAttempt == 0)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
break;
}
throw new TimeoutException($"OpenList 请求超过 120 秒:{request.RequestUri.AbsolutePath}", ex);
}
}
}
throw new InvalidOperationException("OpenList 登录状态无效,请检查账号或密码。");
}
private async Task<string> GetTokenAsync(
ConnectionInfo connection,
bool forceRefresh,
CancellationToken cancellationToken)
{
var key = BuildCacheKey(connection);
if (!forceRefresh && _tokens.TryGetValue(key, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
return cached.Token;
await _loginGate.WaitAsync(cancellationToken);
try
{
if (!forceRefresh && _tokens.TryGetValue(key, out cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
return cached.Token;
using var client = _httpClientFactory.CreateClient("openlist");
using var response = await client.PostAsJsonAsync($"{connection.BaseUrl}/api/auth/login", new
{
username = connection.Username?.Trim() ?? string.Empty,
password = connection.Password ?? string.Empty
}, JsonOptions, cancellationToken);
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
EnsureSuccess(envelope, "OpenList login");
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data
|| !data.TryGetProperty("token", out var tokenElement)
|| string.IsNullOrWhiteSpace(tokenElement.GetString()))
throw new InvalidOperationException("OpenList 登录响应未包含 token。");
var token = tokenElement.GetString()!;
_tokens[key] = new TokenCacheEntry(token, DateTimeOffset.UtcNow.AddMinutes(20));
return token;
}
finally
{
_loginGate.Release();
}
}
private void InvalidateToken(ConnectionInfo connection) =>
_tokens.TryRemove(BuildCacheKey(connection), out _);
private static ConnectionInfo Connect(OpenListSettings settings, string password) =>
new(NormalizeBaseUrl(settings?.Endpoint), settings?.UserName ?? string.Empty, password ?? string.Empty);
private static string BuildCacheKey(ConnectionInfo connection)
{
var raw = $"{connection.BaseUrl}\n{connection.Username}\n{connection.Password}";
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)));
}
private static string BuildRawUrl(string baseUrl, string path, string? rawUrl)
{
if (Uri.TryCreate(rawUrl, UriKind.Absolute, out var absolute)
&& absolute.Scheme is "http" or "https") return absolute.ToString();
if (!string.IsNullOrWhiteSpace(rawUrl)) return new Uri(new Uri(baseUrl + "/"), rawUrl).ToString();
return baseUrl + "/d" + StoragePath.Encode(path);
}
private static bool IsSameOrigin(string baseUrl, string rawUrl)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var origin)
|| !Uri.TryCreate(rawUrl, UriKind.Absolute, out var target)) return false;
return string.Equals(origin.Scheme, target.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(origin.IdnHost, target.IdnHost, StringComparison.OrdinalIgnoreCase)
&& origin.Port == target.Port;
}
private static HttpRequestMessage JsonRequest(HttpMethod method, string path, object payload) =>
new(method, path) { Content = JsonContent.Create(payload, options: JsonOptions) };
private static async Task<ApiEnvelope> ReadEnvelopeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
return new ApiEnvelope((int)response.StatusCode, response.ReasonPhrase ?? "Empty response", null);
try
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
var code = root.TryGetProperty("code", out var codeElement) && codeElement.TryGetInt32(out var parsed)
? parsed : (int)response.StatusCode;
var message = root.TryGetProperty("message", out var messageElement)
? messageElement.GetString() ?? body : body;
return new ApiEnvelope(code, message,
root.TryGetProperty("data", out var data) ? data.Clone() : null);
}
catch (JsonException ex)
{
throw new InvalidOperationException($"OpenList 返回了无效 JSONHTTP {(int)response.StatusCode}):{body}", ex);
}
}
private static bool IsMissing(ApiEnvelope envelope) =>
envelope.Code == 404 || ContainsAny(envelope.Message, "not found", "object not found", "no such file");
private static void EnsureSuccess(ApiEnvelope envelope, string operation)
{
if (envelope.Code != 200)
throw new InvalidOperationException($"{operation} failed with code {envelope.Code}: {envelope.Message}");
}
private static bool ContainsAny(string? value, params string[] candidates) =>
!string.IsNullOrWhiteSpace(value)
&& candidates.Any(candidate => value.Contains(candidate, StringComparison.OrdinalIgnoreCase));
private static string[] SplitPath(string path) =>
StoragePath.NormalizeRemote(path).Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static string GetFileName(string path)
{
var normalized = StoragePath.NormalizeRemote(path);
var name = normalized[(normalized.LastIndexOf('/') + 1)..];
if (string.IsNullOrWhiteSpace(name)) throw new InvalidOperationException($"OpenList 路径 '{path}' 不包含文件名。");
return name;
}
private sealed record ConnectionInfo(string BaseUrl, string Username, string Password);
private sealed record TokenCacheEntry(string Token, DateTimeOffset ExpiresAt);
private sealed record CanonicalPathCacheEntry(string Path, DateTimeOffset ExpiresAt);
private sealed record OpenListDirectoryEntry(string Name, bool IsDirectory, long Size);
private sealed class OpenListDirectorySnapshot
{
public bool CanWrite { get; set; }
public List<OpenListDirectoryEntry> Entries { get; set; } = new();
}
private sealed record ApiEnvelope(int Code, string Message, JsonElement? Data);
}
public sealed record OpenListObjectInfo(
string Name,
long Size,
bool IsDirectory,
IReadOnlyDictionary<string, string> Hashes,
string? RawUrl);
public sealed record OpenListCopyResult(IReadOnlyList<string> TaskIds);
public sealed record OpenListDirectoryInspection(
string Path,
bool Exists,
int EntryCount,
int DirectoryCount,
int FileCount);
public sealed record OpenListTaskInfo(
string Id,
int State,
double Progress,
string Status,
string? Error);
}
+275
View File
@@ -0,0 +1,275 @@
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service;
namespace dy.net.storage
{
public sealed class OpenListMediaStorage : IMediaStorage, ICanonicalMediaStorage
{
private readonly OpenListSettingsService _settingsService;
private readonly OpenListClient _client;
private readonly OpenListTransferService _transfers;
public OpenListMediaStorage(
OpenListSettingsService settingsService,
OpenListClient client,
OpenListTransferService transfers)
{
_settingsService = settingsService;
_client = client;
_transfers = transfers;
}
public StorageType StorageType => StorageType.OpenList;
public async Task<string> CanonicalizePathAsync(
string path,
bool createParentDirectories = false,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync();
var password = await RequirePasswordAsync(settings);
var actual = await _client.ResolveCanonicalObjectPathAsync(settings, password,
OpenListTransferService.ToActualPath(settings, path), createParentDirectories, cancellationToken);
return OpenListTransferService.ToLogicalPath(settings, actual);
}
public async Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync();
var password = await RequirePasswordAsync(settings);
await _client.EnsureDirectoryAsync(settings, password,
OpenListTransferService.ToActualPath(settings, path), cancellationToken);
}
public async Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path)) return false;
var settings = await _settingsService.GetAsync();
var password = await RequirePasswordAsync(settings);
var info = await _client.TryGetObjectAsync(settings, password,
OpenListTransferService.ToActualPath(settings, path), cancellationToken);
return info is { IsDirectory: false };
}
public async Task<long?> GetLengthAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path)) return null;
var settings = await _settingsService.GetAsync();
var password = await RequirePasswordAsync(settings);
var info = await _client.TryGetObjectAsync(settings, password,
OpenListTransferService.ToActualPath(settings, path), cancellationToken);
return info is { IsDirectory: false } ? info.Size : null;
}
public Task WriteAsync(
string path,
Stream source,
long? contentLength = null,
string contentType = null,
CancellationToken cancellationToken = default) =>
_transfers.TransferAsync(path, source, contentLength, cancellationToken);
public async Task<StorageReadResult> OpenReadAsync(
string path,
long? from = null,
long? to = null,
CancellationToken cancellationToken = default)
{
var settings = await _settingsService.GetAsync();
var password = await RequirePasswordAsync(settings);
return await _client.OpenReadAsync(settings, password,
OpenListTransferService.ToActualPath(settings, path), from, to, cancellationToken);
}
public async Task DeleteAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path)) return;
var settings = await _settingsService.GetAsync();
var password = await RequirePasswordAsync(settings);
await _client.DeleteObjectAsync(settings, password,
OpenListTransferService.ToActualPath(settings, path), cancellationToken);
}
public IMediaStorage Bind(OpenListSettings settings)
{
OpenListTransferService.ValidateSettings(settings);
return new BoundOpenListMediaStorage(this, settings);
}
public async Task<OpenListDirectoryListDto> ListDirectoriesAsync(
OpenListSettings settings,
string path,
CancellationToken cancellationToken = default)
{
OpenListTransferService.ValidateSettings(settings);
var password = await RequirePasswordAsync(settings);
return await _client.ListDirectoriesAsync(settings, password, path, true, cancellationToken);
}
/// <summary>
/// 验证登录、本地目录与 OpenList 源挂载映射、服务端复制、Range 读取和删除。
/// </summary>
public async Task<(bool Success, string Message)> ProbeAsync(
OpenListSettings settings,
CancellationToken cancellationToken = default)
{
string localDirectory = null;
string targetDirectory = null;
string password = null;
try
{
OpenListTransferService.ValidateSettings(settings);
password = await RequirePasswordAsync(settings);
var connectionMessage = await _client.ProbeAsync(settings, password, cancellationToken);
var id = Guid.NewGuid().ToString("N");
var bytes = System.Text.Encoding.UTF8.GetBytes("dysync-openlist-copy-probe");
localDirectory = Path.Combine(Path.GetFullPath(settings.LocalStagingPath), id);
Directory.CreateDirectory(localDirectory);
var localPath = Path.Combine(localDirectory, "probe.txt");
await File.WriteAllBytesAsync(localPath, bytes, cancellationToken);
var sourcePath = StoragePath.CombineRemote(settings.SourcePath, id, "probe.txt");
targetDirectory = StoragePath.CombineRemote(settings.BasePath, $".dysync-probe-{id}");
var targetPath = StoragePath.CombineRemote(targetDirectory, "probe.txt");
OpenListObjectInfo source = null;
foreach (var delay in new[] { 0, 500, 1000, 2000, 4000 })
{
if (delay > 0) await Task.Delay(delay, cancellationToken);
source = await _client.TryGetObjectAsync(settings, password, sourcePath, cancellationToken);
if (source is { IsDirectory: false } && source.Size == bytes.Length) break;
}
if (source is not { IsDirectory: false } || source.Size != bytes.Length)
throw new InvalidOperationException(
$"OpenList 无法从源挂载目录看到测试文件。请确认本地目录“{settings.LocalStagingPath}”映射到 OpenList“{settings.SourcePath}”。");
await _client.EnsureDirectoryAsync(settings, password, targetDirectory, cancellationToken);
var copy = await _client.CopyFileAsync(settings, password, sourcePath, targetPath, cancellationToken);
var taskId = copy.TaskIds.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(taskId))
{
var deadline = DateTime.UtcNow.AddMinutes(2);
while (DateTime.UtcNow < deadline)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
var task = await _client.TryGetCopyTaskAsync(settings, password, taskId, cancellationToken);
if (task?.State == 2) break;
if (task?.State is 4 or 7)
throw new IOException(task.Error ?? $"OpenList 复制任务失败:state={task.State}");
}
}
var target = await _client.TryGetObjectAsync(settings, password, targetPath, cancellationToken);
if (target is not { IsDirectory: false } || target.Size != bytes.Length)
throw new IOException($"OpenList 服务端复制校验失败:期望 {bytes.Length},实际 {target?.Size.ToString() ?? ""}。");
await using (var read = await _client.OpenReadAsync(settings, password, targetPath, 0, 0, cancellationToken))
{
var oneByte = new byte[1];
if (await read.Stream.ReadAsync(oneByte.AsMemory(0, 1), cancellationToken) != 1)
throw new IOException("OpenList Range 读取未返回数据。");
}
await _client.DeleteObjectAsync(settings, password, targetDirectory, cancellationToken);
return (true, $"{connectionMessage} 服务端复制、Range 与删除能力正常。");
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "OpenList 完整能力探测失败");
return (false, ex.GetBaseException().Message);
}
finally
{
if (!string.IsNullOrWhiteSpace(targetDirectory) && !string.IsNullOrWhiteSpace(password))
{
try { await _client.DeleteObjectAsync(settings, password, targetDirectory, CancellationToken.None); }
catch { }
}
if (!string.IsNullOrWhiteSpace(localDirectory) && Directory.Exists(localDirectory))
{
try { Directory.Delete(localDirectory, true); }
catch { }
}
}
}
private async Task<string> RequirePasswordAsync(OpenListSettings settings)
{
var password = await _settingsService.GetPasswordAsync(settings);
if (string.IsNullOrWhiteSpace(password))
throw new InvalidOperationException("OpenList 密码无效,请重新输入并保存。");
return password;
}
private sealed class BoundOpenListMediaStorage : IMediaStorage, ICanonicalMediaStorage
{
private readonly OpenListMediaStorage _owner;
private readonly OpenListSettings _settings;
public BoundOpenListMediaStorage(OpenListMediaStorage owner, OpenListSettings settings)
{
_owner = owner;
_settings = settings;
}
public StorageType StorageType => StorageType.OpenList;
public async Task<string> CanonicalizePathAsync(
string path,
bool createParentDirectories = false,
CancellationToken cancellationToken = default)
{
var password = await _owner.RequirePasswordAsync(_settings);
var actual = await _owner._client.ResolveCanonicalObjectPathAsync(_settings, password,
OpenListTransferService.ToActualPath(_settings, path), createParentDirectories, cancellationToken);
return OpenListTransferService.ToLogicalPath(_settings, actual);
}
public async Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default)
{
var password = await _owner.RequirePasswordAsync(_settings);
await _owner._client.EnsureDirectoryAsync(_settings, password,
OpenListTransferService.ToActualPath(_settings, path), cancellationToken);
}
public async Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default) =>
await GetLengthAsync(path, cancellationToken) is > 0;
public async Task<long?> GetLengthAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path)) return null;
var password = await _owner.RequirePasswordAsync(_settings);
var info = await _owner._client.TryGetObjectAsync(_settings, password,
OpenListTransferService.ToActualPath(_settings, path), cancellationToken);
return info is { IsDirectory: false } ? info.Size : null;
}
public async Task WriteAsync(string path, Stream source, long? contentLength = null,
string contentType = null, CancellationToken cancellationToken = default)
{
var current = await _owner._settingsService.GetAsync();
if (!string.Equals(StorageConfigurationFingerprint.Create(current),
StorageConfigurationFingerprint.Create(_settings), StringComparison.Ordinal))
throw new InvalidOperationException("OpenList 配置已变化,绑定存储拒绝写入新目标。");
await _owner._transfers.TransferAsync(path, source, contentLength, cancellationToken);
}
public async Task<StorageReadResult> OpenReadAsync(string path, long? from = null, long? to = null,
CancellationToken cancellationToken = default)
{
var password = await _owner.RequirePasswordAsync(_settings);
return await _owner._client.OpenReadAsync(_settings, password,
OpenListTransferService.ToActualPath(_settings, path), from, to, cancellationToken);
}
public async Task DeleteAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path)) return;
var password = await _owner.RequirePasswordAsync(_settings);
await _owner._client.DeleteObjectAsync(_settings, password,
OpenListTransferService.ToActualPath(_settings, path), cancellationToken);
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
namespace dy.net.storage
{
public static class SafeLocalMigrationFile
{
public static bool TryResolve(string path, IEnumerable<string> allowedRoots, out string fullPath, out string error)
{
fullPath = null;
error = null;
if (string.IsNullOrWhiteSpace(path))
{
error = "本地路径为空";
return false;
}
try { fullPath = Path.GetFullPath(path); }
catch (Exception ex)
{
error = "本地路径无效:" + ex.Message;
return false;
}
var resolvedPath = fullPath;
var root = (allowedRoots ?? Array.Empty<string>())
.Select(Path.GetFullPath)
.Where(candidate => IsWithin(resolvedPath, candidate))
.OrderByDescending(x => x.Length)
.FirstOrDefault();
if (root == null)
{
error = "文件不在账号配置的旧存储根目录内";
return false;
}
if (!File.Exists(fullPath))
{
error = "本地文件不存在";
return false;
}
try
{
for (var current = fullPath; !string.IsNullOrWhiteSpace(current); current = Path.GetDirectoryName(current))
{
FileSystemInfo info = File.Exists(current) ? new FileInfo(current) : new DirectoryInfo(current);
if (!string.IsNullOrWhiteSpace(info.LinkTarget))
{
error = "旧文件路径包含符号链接,已拒绝迁移";
return false;
}
if (string.Equals(current, root, StringComparison.Ordinal)) break;
}
}
catch (Exception ex)
{
error = "无法验证旧文件路径:" + ex.Message;
return false;
}
return true;
}
public static bool IsWithin(string candidate, string root)
{
var fullCandidate = Path.GetFullPath(candidate).TrimEnd(Path.DirectorySeparatorChar);
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal)
|| fullCandidate.StartsWith(fullRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.utils;
using Newtonsoft.Json;
namespace dy.net.storage
{
public static class StorageArtifactCleaner
{
public static async Task DeleteWebDavArtifactsAsync(
IMediaStorage storage,
DouyinVideo video,
CancellationToken cancellationToken = default)
{
if (storage == null || video == null || !video.StorageType.IsRemote()) return;
var paths = new HashSet<string>(StringComparer.Ordinal)
{
video.VideoSavePath,
video.VideoCoverSavePath
};
foreach (var nfoPath in NfoContentBuilder.Build(video).Keys)
{
// tvshow.nfo is shared by every episode in a mix/series directory.
if (!Path.GetFileName(nfoPath).Equals("tvshow.nfo", StringComparison.OrdinalIgnoreCase))
paths.Add(nfoPath);
}
if (!string.IsNullOrWhiteSpace(video.DynamicVideos))
{
try
{
var related = JsonConvert.DeserializeObject<List<DouyinMergeVideoDto>>(video.DynamicVideos);
foreach (var item in related ?? new List<DouyinMergeVideoDto>())
{
if (!string.IsNullOrWhiteSpace(item.Path)) paths.Add(item.Path);
}
}
catch (JsonException ex)
{
Serilog.Log.Warning(ex, "解析远端附属文件清单失败:{VideoId}", video.AwemeId);
}
}
foreach (var path in paths.Where(x => !string.IsNullOrWhiteSpace(x)))
{
try
{
if (StoragePath.NormalizeRemote(path) != "/")
await storage.DeleteAsync(path, cancellationToken);
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "清理远端媒体文件失败:{Path}", path);
}
}
}
}
}
@@ -0,0 +1,31 @@
using System.Security.Cryptography;
using System.Text;
using dy.net.model.entity;
namespace dy.net.storage
{
public static class StorageConfigurationFingerprint
{
public static string Create(WebDavSettings settings)
{
if (settings == null) return string.Empty;
var canonical = string.Join("\n",
(settings.Endpoint ?? string.Empty).Trim().TrimEnd('/'),
StoragePath.NormalizeRemote(settings.BasePath),
(settings.UserName ?? string.Empty).Trim());
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
}
public static string Create(OpenListSettings settings)
{
if (settings == null) return string.Empty;
var canonical = string.Join("\n",
OpenListClient.NormalizeBaseUrl(settings.Endpoint),
StoragePath.NormalizeRemote(settings.BasePath),
Path.GetFullPath(settings.LocalStagingPath ?? string.Empty),
StoragePath.NormalizeRemote(settings.SourcePath),
(settings.UserName ?? string.Empty).Trim());
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
}
}
}
+155
View File
@@ -0,0 +1,155 @@
using System.Text.RegularExpressions;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.model.response;
using dy.net.utils;
namespace dy.net.storage
{
public sealed class StorageMigrationPathPlan
{
public string VideoPath { get; set; }
public string CoverPath { get; set; }
public string AvatarPath { get; set; }
public string DirectoryPath => StoragePath.DirectoryName(VideoPath);
}
public static class StorageMigrationPathPolicy
{
private static readonly Regex EpisodeName = new("^S(?<season>\\d{2})E(?<episode>\\d{2,})$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static StorageMigrationPathPlan Build(
DouyinVideo video,
DouyinCookie cookie,
DouyinCollectCate category,
DouyinFollowed followed,
AppConfig config,
int episodeNumber = 1)
{
if (video == null) throw new ArgumentNullException(nameof(video));
if (cookie == null) throw new InvalidOperationException($"视频 {video.AwemeId} 缺少所属账号");
var root = GetRemoteRoot(cookie, video.ViedoType);
if (string.IsNullOrWhiteSpace(root)) throw new InvalidOperationException($"账号 {cookie.UserName} 未配置 {video.ViedoType} 的远端目标路径");
var extension = NormalizeExtension(Path.GetExtension(video.VideoSavePath));
var awemeId = string.IsNullOrWhiteSpace(video.AwemeId) ? video.Id : video.AwemeId;
var titleFolder = DouyinFileNameHelper.SanitizeLinuxFileName(video.VideoTitle, awemeId, true);
var authorFolder = DouyinFileNameHelper.SanitizeLinuxFileName(video.Author, video.AuthorId ?? "未知博主", true);
string directory;
string fileName;
switch (video.ViedoType)
{
case VideoTypeEnum.dy_custom_collect:
var customCategory = DouyinFileNameHelper.SanitizeLinuxFileName(category?.SaveFolder, category?.Name ?? "未分类", true);
directory = StoragePath.CombineRemote(root, customCategory, titleFolder + "_" + awemeId);
fileName = awemeId + extension;
break;
case VideoTypeEnum.dy_mix:
case VideoTypeEnum.dy_series:
var seriesFolder = DouyinFileNameHelper.SanitizeLinuxFileName(category?.SaveFolder, category?.Name ?? video.CateXId ?? "未分类", true);
directory = StoragePath.CombineRemote(root, seriesFolder);
var oldEpisode = EpisodeName.Match(Path.GetFileNameWithoutExtension(video.VideoSavePath) ?? string.Empty);
fileName = oldEpisode.Success
? oldEpisode.Value.ToUpperInvariant() + extension
: $"S01E{Math.Max(1, episodeNumber):D2}{extension}";
break;
case VideoTypeEnum.dy_follows:
var followedFolder = string.IsNullOrWhiteSpace(followed?.SavePath) ? authorFolder : followed.SavePath;
directory = StoragePath.CombineRemote(root, followedFolder, titleFolder);
fileName = BuildFollowedFileName(video, config, extension, awemeId);
break;
case VideoTypeEnum.dy_favorite:
case VideoTypeEnum.dy_collects:
default:
directory = StoragePath.CombineRemote(root, authorFolder, titleFolder + "_" + awemeId);
fileName = awemeId + extension;
break;
}
var videoPath = StoragePath.CombineRemote(directory, fileName);
var coverName = video.ViedoType is VideoTypeEnum.dy_mix or VideoTypeEnum.dy_series
? "poster.jpg"
: Path.GetFileNameWithoutExtension(fileName) + "-poster.jpg";
return new StorageMigrationPathPlan
{
VideoPath = videoPath,
CoverPath = StoragePath.CombineRemote(directory, coverName),
AvatarPath = string.IsNullOrWhiteSpace(video.AuthorAvatar) && string.IsNullOrWhiteSpace(video.AuthorAvatarUrl)
? string.Empty
: StoragePath.CombineRemote(root, "author", (string.IsNullOrWhiteSpace(video.AuthorId) ? awemeId : video.AuthorId) + ".jpg")
};
}
public static StorageMigrationPathPlan Build(
Aweme item,
VideoTypeEnum type,
DouyinCookie cookie,
DouyinCollectCate category,
DouyinFollowed followed,
AppConfig config)
{
if (item == null) throw new ArgumentNullException(nameof(item));
var bitrate = item.Video?.BitRate?.FirstOrDefault();
var format = string.IsNullOrWhiteSpace(bitrate?.Format) ? "mp4" : bitrate.Format.TrimStart('.');
var createdAt = DateTimeUtil.Convert10BitTimestamp(item.CreateTime);
var title = string.IsNullOrWhiteSpace(item.Desc)
? $"{item.Author?.Nickname}-{item.CreateTime}"
: item.Desc;
var video = new DouyinVideo
{
ViedoType = type,
AwemeId = item.AwemeId,
VideoTitle = title,
VideoSavePath = $"{item.AwemeId}.{format}",
Author = item.Author?.Nickname,
AuthorId = item.Author?.Uid,
AuthorAvatarUrl = item.Author?.AvatarLarger?.UrlList?.FirstOrDefault()
?? item.Author?.AvatarThumb?.UrlList?.FirstOrDefault(),
CreateTime = createdAt,
FileHash = bitrate?.PlayAddr?.FileHash,
Resolution = bitrate?.PlayAddr == null ? string.Empty : $"{bitrate.PlayAddr.Width}×{bitrate.PlayAddr.Height}"
};
var episode = item.MixInfo?.Statis?.CurrentEpisode ?? 1;
return Build(video, cookie, category, followed, config, Math.Max(1, episode));
}
public static string GetRemoteRoot(DouyinCookie cookie, VideoTypeEnum type) => type switch
{
VideoTypeEnum.dy_favorite => cookie.WebDavFavoritePath,
VideoTypeEnum.dy_follows => cookie.WebDavFollowPath,
VideoTypeEnum.dy_mix => cookie.WebDavMixPath,
VideoTypeEnum.dy_series => cookie.WebDavSeriesPath,
_ => cookie.WebDavCollectPath
};
public static IReadOnlyList<string> GetLocalRoots(DouyinCookie cookie)
{
if (cookie == null) return Array.Empty<string>();
return new[] { cookie.SavePath, cookie.FavSavePath, cookie.UpSavePath, cookie.MixPath, cookie.SeriesPath }
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(Path.GetFullPath)
.Distinct(StringComparer.Ordinal)
.ToArray();
}
private static string BuildFollowedFileName(DouyinVideo video, AppConfig config, string extension, string awemeId)
{
if (string.IsNullOrWhiteSpace(config?.FullFollowedTitleTemplate)) return awemeId + extension;
var generated = VideoTitleGenerator.Generate(config.FullFollowedTitleTemplate, new VideoTitleDataTemplate
{
Id = awemeId,
ReleaseTime = video.CreateTime,
VideoTitle = video.VideoTitle,
Author = video.Author,
FileHash = video.FileHash,
Resolution = video.Resolution
});
generated = DouyinFileNameHelper.SanitizeLinuxFileName(generated, awemeId);
return generated + extension;
}
private static string NormalizeExtension(string extension) => string.IsNullOrWhiteSpace(extension) ? ".mp4" : extension.ToLowerInvariant();
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace dy.net.storage
{
public static class StoragePath
{
public static string NormalizeRemote(string path)
{
if (string.IsNullOrWhiteSpace(path)) return "/";
var normalized = path.Replace('\\', '/').Trim();
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Any(x => x == "." || x == ".."))
throw new ArgumentException("远端存储路径不能包含 . 或 ..", nameof(path));
return "/" + string.Join('/', segments);
}
public static string CombineRemote(params string[] parts)
{
return NormalizeRemote(string.Join('/', parts.Where(x => !string.IsNullOrWhiteSpace(x))));
}
public static string DirectoryName(string path)
{
var normalized = NormalizeRemote(path);
var lastSlash = normalized.LastIndexOf('/');
return lastSlash <= 0 ? "/" : normalized[..lastSlash];
}
public static string Encode(string path)
{
var normalized = NormalizeRemote(path);
return string.Join('/', normalized.Split('/').Select(Uri.EscapeDataString));
}
}
}
+18
View File
@@ -0,0 +1,18 @@
namespace dy.net.storage
{
public sealed class StorageReadResult : IAsyncDisposable
{
public Stream Stream { get; init; }
public long? ContentLength { get; init; }
public string ContentType { get; init; }
public string ContentRange { get; init; }
public int StatusCode { get; init; }
public IAsyncDisposable Owner { get; init; }
public async ValueTask DisposeAsync()
{
if (Stream != null) await Stream.DisposeAsync();
if (Owner != null) await Owner.DisposeAsync();
}
}
}
+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);
}
}
}