888 lines
45 KiB
C#
888 lines
45 KiB
C#
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);
|
||
}
|