feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 返回了无效 JSON(HTTP {(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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user