Files
douyin/storage/OpenListClient.cs

888 lines
45 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using dy.net.model.dto;
using dy.net.model.entity;
namespace dy.net.storage
{
/// <summary>
/// OpenList 原生 API 客户端。所有需要认证的请求共用 token 缓存,401 时只重新登录一次。
/// </summary>
public sealed class OpenListClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan CanonicalPathCacheLifetime = TimeSpan.FromMinutes(30);
private static readonly TimeSpan[] CreatedDirectoryVisibilityDelays =
{
TimeSpan.Zero,
TimeSpan.FromMilliseconds(500),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(4)
};
private readonly IHttpClientFactory _httpClientFactory;
private readonly Func<TimeSpan, CancellationToken, Task> _delayAsync;
private readonly ConcurrentDictionary<string, TokenCacheEntry> _tokens = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CanonicalPathCacheEntry> _canonicalDirectories = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, SemaphoreSlim> _directoryGates = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _loginGate = new(1, 1);
public OpenListClient(IHttpClientFactory httpClientFactory)
: this(httpClientFactory, Task.Delay)
{
}
internal OpenListClient(
IHttpClientFactory httpClientFactory,
Func<TimeSpan, CancellationToken, Task> delayAsync)
{
_httpClientFactory = httpClientFactory;
_delayAsync = delayAsync ?? Task.Delay;
}
public async Task<string> ProbeAsync(
OpenListSettings settings,
string password,
CancellationToken cancellationToken = default)
{
var connection = Connect(settings, password);
_ = await GetTokenAsync(connection, true, cancellationToken);
using var client = _httpClientFactory.CreateClient("openlist");
using var response = await client.GetAsync($"{connection.BaseUrl}/api/public/settings", cancellationToken);
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
EnsureSuccess(envelope, "OpenList connection test");
var version = envelope.Data is { ValueKind: JsonValueKind.Object } data
&& data.TryGetProperty("version", out var versionElement)
? versionElement.GetString()
: null;
return string.IsNullOrWhiteSpace(version)
? "OpenList 连接和登录成功。"
: $"OpenList 连接和登录成功:{version}";
}
public async Task<OpenListDirectoryListDto> ListDirectoriesAsync(
OpenListSettings settings,
string password,
string path,
bool refresh,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
path = await ResolveDirectoryCoreAsync(connection, path, false, refresh, cancellationToken);
var directory = await ListDirectoryEntriesCoreAsync(connection, path, refresh, cancellationToken);
var result = new OpenListDirectoryListDto { Path = path, CanWrite = directory.CanWrite };
foreach (var item in directory.Entries.Where(x => x.IsDirectory))
result.Directories.Add(new OpenListDirectoryItemDto { Name = item.Name, Path = StoragePath.CombineRemote(path, item.Name) });
result.Directories = result.Directories.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase).ToList();
return result;
}
public async Task<OpenListDirectoryInspection> InspectDirectoryAsync(
OpenListSettings settings,
string password,
string path,
bool refresh = true,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
var canonical = await ResolveDirectoryCoreAsync(connection, path, false, refresh, cancellationToken);
try
{
var listing = await ListDirectoryEntriesCoreAsync(connection, canonical, refresh, cancellationToken);
return new OpenListDirectoryInspection(canonical, true, listing.Entries.Count,
listing.Entries.Count(x => x.IsDirectory), listing.Entries.Count(x => !x.IsDirectory));
}
catch (Exception ex) when (IsMissingDirectoryError(ex))
{
return new OpenListDirectoryInspection(canonical, false, 0, 0, 0);
}
}
/// <summary>
/// Inspects a directory whose exact server-side path came from a fresh parent listing.
/// This deliberately skips segment-by-segment canonical resolution, which would otherwise
/// refresh a very large parent once for every repair candidate.
/// </summary>
public async Task<OpenListDirectoryInspection> InspectKnownDirectoryAsync(
OpenListSettings settings,
string password,
string exactPath,
bool refresh = true,
CancellationToken cancellationToken = default)
{
exactPath = StoragePath.NormalizeRemote(exactPath);
var connection = Connect(settings, password);
try
{
var listing = await ListDirectoryEntriesCoreAsync(connection, exactPath, refresh, cancellationToken);
return new OpenListDirectoryInspection(exactPath, true, listing.Entries.Count,
listing.Entries.Count(x => x.IsDirectory), listing.Entries.Count(x => !x.IsDirectory));
}
catch (Exception ex) when (IsMissingDirectoryError(ex))
{
return new OpenListDirectoryInspection(exactPath, false, 0, 0, 0);
}
}
public async Task<string> EnsureDirectoryAsync(
OpenListSettings settings,
string password,
string path,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
return await ResolveDirectoryCoreAsync(connection, path, true, false, cancellationToken);
}
/// <summary>
/// Resolves every existing directory segment through the parent's listing and returns the
/// server-side spelling. This avoids fs/get inconsistencies on case-insensitive cloud
/// drivers (for example a logical Kk path whose real directory is KK).
/// </summary>
public async Task<string> ResolveCanonicalObjectPathAsync(
OpenListSettings settings,
string password,
string path,
bool createParentDirectories = false,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
if (path == "/") return path;
var connection = Connect(settings, password);
var parent = await ResolveDirectoryCoreAsync(connection, StoragePath.DirectoryName(path),
createParentDirectories, false, cancellationToken);
var requestedName = GetFileName(path);
OpenListDirectorySnapshot entries;
try
{
entries = await ListDirectoryEntriesCoreAsync(connection, parent, false, cancellationToken);
}
catch (Exception ex) when (!createParentDirectories && IsMissingDirectoryError(ex))
{
// A new media path normally contains a per-video directory that does not exist
// until the transfer starts. Canonicalize the existing prefix, then preserve the
// missing tail instead of treating OpenList's object-not-found response as a
// storage failure before the first download attempt.
return StoragePath.CombineRemote(parent, requestedName);
}
var existing = SelectCanonicalEntry(entries.Entries, requestedName, null, path);
return StoragePath.CombineRemote(parent, existing?.Name ?? requestedName);
}
public async Task<OpenListObjectInfo?> TryGetObjectAsync(
OpenListSettings settings,
string password,
string path,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
var canonical = await ResolveObjectPathCoreAsync(connection, path, false, cancellationToken);
var result = await TryGetObjectCoreAsync(connection, canonical, cancellationToken);
if (result != null || path == "/") return result;
InvalidateCanonicalDirectory(connection, StoragePath.DirectoryName(path));
canonical = await ResolveObjectPathCoreAsync(connection, path, true, cancellationToken);
if (!await TryRefreshDirectoryAsync(connection, StoragePath.DirectoryName(canonical), cancellationToken)) return null;
return await TryGetObjectCoreAsync(connection, canonical, cancellationToken);
}
public async Task DeleteObjectAsync(
OpenListSettings settings,
string password,
string path,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
if (path == "/") throw new InvalidOperationException("拒绝删除 OpenList 根目录。");
var connection = Connect(settings, password);
path = await ResolveObjectPathCoreAsync(connection, path, true, cancellationToken);
var envelope = await SendAuthorizedAsync(
connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/remove", new
{
names = new[] { GetFileName(path) },
dir = StoragePath.DirectoryName(path)
}), cancellationToken);
if (IsMissing(envelope)) return;
EnsureSuccess(envelope, $"OpenList remove '{path}'");
}
public async Task DeleteKnownObjectAsync(
OpenListSettings settings,
string password,
string exactPath,
CancellationToken cancellationToken = default)
{
exactPath = StoragePath.NormalizeRemote(exactPath);
if (exactPath == "/") throw new InvalidOperationException("拒绝删除 OpenList 根目录。");
var connection = Connect(settings, password);
var envelope = await SendAuthorizedAsync(
connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/remove", new
{
names = new[] { GetFileName(exactPath) },
dir = StoragePath.DirectoryName(exactPath)
}), cancellationToken);
if (IsMissing(envelope)) return;
EnsureSuccess(envelope, $"OpenList remove known object '{exactPath}'");
}
public async Task<OpenListCopyResult> CopyFileAsync(
OpenListSettings settings,
string password,
string sourcePath,
string targetPath,
CancellationToken cancellationToken = default)
{
sourcePath = StoragePath.NormalizeRemote(sourcePath);
targetPath = StoragePath.NormalizeRemote(targetPath);
var connection = Connect(settings, password);
sourcePath = await ResolveObjectPathCoreAsync(connection, sourcePath, false, cancellationToken);
var targetParent = await ResolveDirectoryCoreAsync(connection, StoragePath.DirectoryName(targetPath), true, false, cancellationToken);
targetPath = StoragePath.CombineRemote(targetParent, GetFileName(targetPath));
if (!string.Equals(GetFileName(sourcePath), GetFileName(targetPath), StringComparison.Ordinal))
throw new InvalidOperationException("OpenList 服务端复制要求源文件名与暂存文件名一致。");
var envelope = await SendAuthorizedAsync(
connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/copy", new
{
src_dir = StoragePath.DirectoryName(sourcePath),
dst_dir = StoragePath.DirectoryName(targetPath),
names = new[] { GetFileName(sourcePath) },
overwrite = false,
skip_existing = false,
merge = false
}), cancellationToken);
EnsureSuccess(envelope, $"OpenList copy '{sourcePath}' to '{targetPath}'");
var ids = new List<string>();
if (envelope.Data is { ValueKind: JsonValueKind.Object } data
&& data.TryGetProperty("tasks", out var tasks)
&& tasks.ValueKind == JsonValueKind.Array)
{
foreach (var task in tasks.EnumerateArray())
{
var id = task.TryGetProperty("id", out var idElement) ? idElement.GetString() : null;
if (!string.IsNullOrWhiteSpace(id)) ids.Add(id);
}
}
return new OpenListCopyResult(ids);
}
public async Task<OpenListTaskInfo?> TryGetCopyTaskAsync(
OpenListSettings settings,
string password,
string taskId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(taskId)) return null;
var encoded = Uri.EscapeDataString(taskId.Trim());
var envelope = await SendAuthorizedAsync(Connect(settings, password),
() => new HttpRequestMessage(HttpMethod.Post, $"/api/task/copy/info?tid={encoded}"),
cancellationToken);
if (IsMissing(envelope)) return null;
EnsureSuccess(envelope, $"OpenList copy task '{taskId}'");
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data) return null;
return new OpenListTaskInfo(
data.TryGetProperty("id", out var id) ? id.GetString() ?? taskId : taskId,
data.TryGetProperty("state", out var state) && state.TryGetInt32(out var stateValue) ? stateValue : -1,
data.TryGetProperty("progress", out var progress) && progress.TryGetDouble(out var progressValue) ? progressValue : 0,
data.TryGetProperty("status", out var status) ? status.GetString() ?? string.Empty : string.Empty,
data.TryGetProperty("error", out var error) ? error.GetString() : null);
}
public async Task<bool> TryCancelCopyTaskAsync(
OpenListSettings settings,
string password,
string taskId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(taskId)) return true;
var encoded = Uri.EscapeDataString(taskId.Trim());
var envelope = await SendAuthorizedAsync(Connect(settings, password),
() => new HttpRequestMessage(HttpMethod.Post, $"/api/task/copy/cancel?tid={encoded}"),
cancellationToken);
return envelope.Code == 200 || IsMissing(envelope) || ContainsAny(envelope.Message, "finished");
}
public async Task RenameAsync(
OpenListSettings settings,
string password,
string path,
string newName,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
if (string.IsNullOrWhiteSpace(newName) || newName.Contains('/') || newName.Contains('\\'))
throw new InvalidOperationException("OpenList 新文件名无效。");
var connection = Connect(settings, password);
path = await ResolveObjectPathCoreAsync(connection, path, true, cancellationToken);
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/rename", new { path, name = newName.Trim() }),
cancellationToken);
EnsureSuccess(envelope, $"OpenList rename '{path}'");
}
public async Task MoveAsync(
OpenListSettings settings,
string password,
string sourcePath,
string targetDirectory,
bool overwrite,
CancellationToken cancellationToken = default)
{
sourcePath = StoragePath.NormalizeRemote(sourcePath);
targetDirectory = StoragePath.NormalizeRemote(targetDirectory);
var connection = Connect(settings, password);
sourcePath = await ResolveObjectPathCoreAsync(connection, sourcePath, true, cancellationToken);
targetDirectory = await ResolveDirectoryCoreAsync(connection, targetDirectory, true, false, cancellationToken);
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/move", new
{
src_dir = StoragePath.DirectoryName(sourcePath),
dst_dir = targetDirectory,
names = new[] { GetFileName(sourcePath) },
overwrite
}), cancellationToken);
EnsureSuccess(envelope, $"OpenList move '{sourcePath}' to '{targetDirectory}'");
}
public async Task<StorageReadResult> OpenReadAsync(
OpenListSettings settings,
string password,
string path,
long? from,
long? to,
CancellationToken cancellationToken = default)
{
path = StoragePath.NormalizeRemote(path);
var connection = Connect(settings, password);
path = await ResolveObjectPathCoreAsync(connection, path, true, cancellationToken);
var info = await TryGetObjectCoreAsync(connection, path, cancellationToken)
?? throw new FileNotFoundException("OpenList 媒体文件不存在", path);
var rawUrl = BuildRawUrl(connection.BaseUrl, path, info.RawUrl);
// OpenList can return a signed URL hosted by the underlying cloud driver.
// Its login token must never be forwarded to a different origin; some
// object-storage providers also reject that unrelated Authorization header.
var authorizeRawRequest = IsSameOrigin(connection.BaseUrl, rawUrl);
var attemptCount = authorizeRawRequest ? 2 : 1;
for (var attempt = 0; attempt < attemptCount; attempt++)
{
var client = _httpClientFactory.CreateClient("openlist");
using var request = new HttpRequestMessage(HttpMethod.Get, rawUrl);
if (authorizeRawRequest)
{
var token = await GetTokenAsync(connection, attempt > 0, cancellationToken);
request.Headers.TryAddWithoutValidation("Authorization", token);
}
if (from.HasValue) request.Headers.Range = new RangeHeaderValue(from, to);
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (authorizeRawRequest && response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
InvalidateToken(connection);
continue;
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
response.Dispose();
throw new FileNotFoundException("OpenList 媒体文件不存在", path);
}
response.EnsureSuccessStatusCode();
return new StorageReadResult
{
Stream = await response.Content.ReadAsStreamAsync(cancellationToken),
ContentLength = response.Content.Headers.ContentLength,
ContentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream",
ContentRange = response.Content.Headers.ContentRange?.ToString(),
StatusCode = (int)response.StatusCode,
Owner = new HttpResponseOwner(response)
};
}
throw new InvalidOperationException("OpenList 登录状态无效,请检查账号或密码。");
}
public static string NormalizeBaseUrl(string? baseUrl)
{
if (!Uri.TryCreate(baseUrl?.Trim(), UriKind.Absolute, out var uri)
|| uri.Scheme is not ("http" or "https"))
throw new InvalidOperationException("OpenList 地址必须是有效的 HTTP/HTTPS URL。");
var path = uri.AbsolutePath.TrimEnd('/');
var davIndex = path.IndexOf("/dav", StringComparison.OrdinalIgnoreCase);
if (davIndex >= 0 && (davIndex + 4 == path.Length || path[davIndex + 4] == '/')) path = path[..davIndex];
return new UriBuilder(uri) { Path = path, Query = string.Empty, Fragment = string.Empty }
.Uri.ToString().TrimEnd('/');
}
private async Task<OpenListObjectInfo?> TryGetObjectCoreAsync(
ConnectionInfo connection,
string path,
CancellationToken cancellationToken)
{
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/get", new { path, password = string.Empty }),
cancellationToken);
if (IsMissing(envelope)) return null;
EnsureSuccess(envelope, $"OpenList get '{path}'");
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data) return null;
var hashes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (data.TryGetProperty("hash_info", out var hashInfo) && hashInfo.ValueKind == JsonValueKind.Object)
{
foreach (var property in hashInfo.EnumerateObject())
{
var value = property.Value.GetString();
if (!string.IsNullOrWhiteSpace(value)) hashes[property.Name] = value;
}
}
return new OpenListObjectInfo(
data.TryGetProperty("name", out var name) ? name.GetString() ?? string.Empty : string.Empty,
data.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0,
data.TryGetProperty("is_dir", out var isDirectory) && isDirectory.GetBoolean(),
hashes,
data.TryGetProperty("raw_url", out var rawUrl) ? rawUrl.GetString() : null);
}
private async Task<string> ResolveObjectPathCoreAsync(
ConnectionInfo connection,
string path,
bool forceRefresh,
CancellationToken cancellationToken)
{
path = StoragePath.NormalizeRemote(path);
if (path == "/") return path;
var parent = await ResolveDirectoryCoreAsync(connection, StoragePath.DirectoryName(path), false,
forceRefresh, cancellationToken);
var requestedName = GetFileName(path);
OpenListDirectorySnapshot listing;
try
{
listing = await ListDirectoryEntriesCoreAsync(connection, parent, forceRefresh, cancellationToken);
}
catch (Exception ex) when (IsMissingDirectoryError(ex))
{
// A missing parent is a normal negative lookup for a brand-new media path.
// This must also apply to the forced refresh performed by TryGetObjectAsync;
// otherwise its second pass turns OpenList's code-500 object-not-found response
// into a storage failure before the transfer has had a chance to create the path.
return StoragePath.CombineRemote(parent, requestedName);
}
var entry = SelectCanonicalEntry(listing.Entries, requestedName, null, path);
return StoragePath.CombineRemote(parent, entry?.Name ?? requestedName);
}
private async Task<string> ResolveDirectoryCoreAsync(
ConnectionInfo connection,
string path,
bool createMissing,
bool forceRefresh,
CancellationToken cancellationToken)
{
path = StoragePath.NormalizeRemote(path);
if (path == "/") return path;
var requested = string.Empty;
var actual = string.Empty;
var segments = SplitPath(path);
for (var index = 0; index < segments.Length; index++)
{
var segment = segments[index];
requested = StoragePath.CombineRemote(requested, segment);
var cacheKey = BuildCanonicalCacheKey(connection, requested);
if (!forceRefresh && _canonicalDirectories.TryGetValue(cacheKey, out var cached)
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
{
actual = cached.Path;
continue;
}
var gate = _directoryGates.GetOrAdd(cacheKey, _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken);
try
{
if (!forceRefresh && _canonicalDirectories.TryGetValue(cacheKey, out cached)
&& cached.ExpiresAt > DateTimeOffset.UtcNow)
{
actual = cached.Path;
continue;
}
OpenListDirectorySnapshot parent;
try
{
parent = await ListDirectoryEntriesCoreAsync(connection,
string.IsNullOrWhiteSpace(actual) ? "/" : actual, forceRefresh, cancellationToken);
}
catch (Exception ex) when (!createMissing && IsMissingDirectoryError(ex))
{
return AppendRemaining(actual, segments, index);
}
var match = SelectCanonicalEntry(parent.Entries, segment, true, requested);
if (match == null && !forceRefresh)
{
parent = await ListDirectoryEntriesCoreAsync(connection,
string.IsNullOrWhiteSpace(actual) ? "/" : actual, true, cancellationToken);
match = SelectCanonicalEntry(parent.Entries, segment, true, requested);
}
if (match == null && !createMissing) return AppendRemaining(actual, segments, index);
if (match == null)
{
var parentPath = string.IsNullOrWhiteSpace(actual) ? "/" : actual;
var desired = StoragePath.CombineRemote(parentPath, segment);
ApiEnvelope envelope;
try
{
envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/mkdir", new { path = desired }),
cancellationToken);
}
catch (TimeoutException)
{
var afterTimeout = await ListDirectoryEntriesCoreAsync(connection, parentPath, true, cancellationToken);
match = SelectCanonicalEntry(afterTimeout.Entries, segment, true, requested);
if (match == null) throw;
envelope = new ApiEnvelope(200, "verified after timeout", null);
}
if (envelope.Code != 200 && !ContainsAny(envelope.Message, "exist", "already"))
EnsureSuccess(envelope, $"OpenList mkdir '{desired}'");
match = await WaitForCreatedDirectoryAsync(
connection, parentPath, segment, requested, cancellationToken);
if (match == null)
throw new InvalidOperationException(
$"OpenList 创建目录 '{desired}' 后经多次刷新仍未出现同名目录。底层存储可能自动改名,已停止继续写入以避免产生重复目录。");
}
if (!match.IsDirectory)
throw new InvalidOperationException($"OpenList 路径 '{requested}' 已存在但不是目录。");
actual = StoragePath.CombineRemote(string.IsNullOrWhiteSpace(actual) ? "/" : actual, match.Name);
_canonicalDirectories[cacheKey] = new CanonicalPathCacheEntry(actual,
DateTimeOffset.UtcNow.Add(CanonicalPathCacheLifetime));
}
finally
{
gate.Release();
}
}
return StoragePath.NormalizeRemote(actual);
}
private async Task<OpenListDirectoryEntry?> WaitForCreatedDirectoryAsync(
ConnectionInfo connection,
string parentPath,
string segment,
string requestedPath,
CancellationToken cancellationToken)
{
foreach (var delay in CreatedDirectoryVisibilityDelays)
{
if (delay > TimeSpan.Zero) await _delayAsync(delay, cancellationToken);
try
{
var refreshed = await ListDirectoryEntriesCoreAsync(
connection, parentPath, true, cancellationToken);
var match = SelectCanonicalEntry(refreshed.Entries, segment, true, requestedPath);
if (match != null) return match;
}
catch (Exception ex) when (IsMissingDirectoryError(ex))
{
// Some remote drivers acknowledge mkdir before the refreshed parent becomes
// readable. Keep the retry bounded and cancellation-aware.
}
}
return null;
}
private async Task<OpenListDirectorySnapshot> ListDirectoryEntriesCoreAsync(
ConnectionInfo connection,
string path,
bool refresh,
CancellationToken cancellationToken)
{
path = StoragePath.NormalizeRemote(path);
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/list", new
{
path,
password = string.Empty,
refresh,
page = 1,
per_page = 0
}), cancellationToken, retryTransient: true);
EnsureSuccess(envelope, $"OpenList list '{path}'");
var result = new OpenListDirectorySnapshot();
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data) return result;
result.CanWrite = data.TryGetProperty("write", out var writable) && writable.ValueKind == JsonValueKind.True;
AddEntries(data, "content", result.Entries);
AddEntries(data, "related", result.Entries);
result.Entries = result.Entries
.GroupBy(x => $"{x.Name}\n{x.IsDirectory}", StringComparer.Ordinal)
.Select(x => x.First()).ToList();
return result;
}
private static void AddEntries(JsonElement data, string propertyName, List<OpenListDirectoryEntry> target)
{
if (!data.TryGetProperty(propertyName, out var content) || content.ValueKind != JsonValueKind.Array) return;
foreach (var item in content.EnumerateArray())
{
var name = item.TryGetProperty("name", out var nameElement) ? nameElement.GetString() : null;
if (string.IsNullOrWhiteSpace(name)) continue;
target.Add(new OpenListDirectoryEntry(
name,
item.TryGetProperty("is_dir", out var isDirectory) && isDirectory.GetBoolean(),
item.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0));
}
}
private static OpenListDirectoryEntry? SelectCanonicalEntry(
IReadOnlyList<OpenListDirectoryEntry> entries,
string requestedName,
bool? requireDirectory,
string requestedPath)
{
var candidates = entries.Where(x => !requireDirectory.HasValue || x.IsDirectory == requireDirectory.Value).ToList();
var exact = candidates.Where(x => string.Equals(x.Name, requestedName, StringComparison.Ordinal)).ToList();
if (exact.Count == 1) return exact[0];
if (exact.Count > 1) throw new InvalidOperationException($"OpenList 路径 '{requestedPath}' 返回了多个完全同名对象。");
var insensitive = candidates.Where(x => string.Equals(x.Name, requestedName, StringComparison.OrdinalIgnoreCase)).ToList();
if (insensitive.Count == 1) return insensitive[0];
if (insensitive.Count > 1)
throw new InvalidOperationException(
$"OpenList 路径 '{requestedPath}' 存在多个仅大小写不同的候选:{string.Join("", insensitive.Select(x => x.Name))}。");
return null;
}
private static string AppendRemaining(string actual, IReadOnlyList<string> segments, int startIndex)
{
var result = string.IsNullOrWhiteSpace(actual) ? "/" : actual;
for (var i = startIndex; i < segments.Count; i++) result = StoragePath.CombineRemote(result, segments[i]);
return result;
}
private void InvalidateCanonicalDirectory(ConnectionInfo connection, string requestedDirectory)
{
requestedDirectory = StoragePath.NormalizeRemote(requestedDirectory);
foreach (var key in _canonicalDirectories.Keys.Where(x => x.StartsWith(BuildCacheKey(connection) + "\n", StringComparison.Ordinal)))
{
if (key.EndsWith("\n" + requestedDirectory, StringComparison.Ordinal)
|| key.Contains("\n" + requestedDirectory.TrimEnd('/') + "/", StringComparison.Ordinal))
_canonicalDirectories.TryRemove(key, out _);
}
}
private static string BuildCanonicalCacheKey(ConnectionInfo connection, string requestedPath) =>
BuildCacheKey(connection) + "\n" + StoragePath.NormalizeRemote(requestedPath);
private static bool IsMissingDirectoryError(Exception ex) =>
ContainsAny(ex.GetBaseException().Message, "not found", "object not found", "no such file", "get dir");
private async Task<bool> TryRefreshDirectoryAsync(
ConnectionInfo connection,
string path,
CancellationToken cancellationToken)
{
var envelope = await SendAuthorizedAsync(connection,
() => JsonRequest(HttpMethod.Post, "/api/fs/list", new
{
path,
password = string.Empty,
refresh = true,
page = 1,
per_page = 0
}), cancellationToken);
if (envelope.Code == 200) return true;
if (IsMissing(envelope)) return false;
EnsureSuccess(envelope, $"OpenList refresh '{path}'");
return true;
}
private async Task<ApiEnvelope> SendAuthorizedAsync(
ConnectionInfo connection,
Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken,
bool retryTransient = false)
{
for (var transientAttempt = 0; transientAttempt < (retryTransient ? 2 : 1); transientAttempt++)
{
for (var authAttempt = 0; authAttempt < 2; authAttempt++)
{
var token = await GetTokenAsync(connection, authAttempt > 0, cancellationToken);
using var client = _httpClientFactory.CreateClient("openlist");
using var request = requestFactory();
request.RequestUri = new Uri(connection.BaseUrl + request.RequestUri, UriKind.Absolute);
request.Headers.TryAddWithoutValidation("Authorization", token);
try
{
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
if (response.StatusCode != HttpStatusCode.Unauthorized && envelope.Code != 401) return envelope;
InvalidateToken(connection);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
if (retryTransient && transientAttempt == 0)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
break;
}
throw new TimeoutException($"OpenList 请求超过 120 秒:{request.RequestUri.AbsolutePath}", ex);
}
}
}
throw new InvalidOperationException("OpenList 登录状态无效,请检查账号或密码。");
}
private async Task<string> GetTokenAsync(
ConnectionInfo connection,
bool forceRefresh,
CancellationToken cancellationToken)
{
var key = BuildCacheKey(connection);
if (!forceRefresh && _tokens.TryGetValue(key, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
return cached.Token;
await _loginGate.WaitAsync(cancellationToken);
try
{
if (!forceRefresh && _tokens.TryGetValue(key, out cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
return cached.Token;
using var client = _httpClientFactory.CreateClient("openlist");
using var response = await client.PostAsJsonAsync($"{connection.BaseUrl}/api/auth/login", new
{
username = connection.Username?.Trim() ?? string.Empty,
password = connection.Password ?? string.Empty
}, JsonOptions, cancellationToken);
var envelope = await ReadEnvelopeAsync(response, cancellationToken);
EnsureSuccess(envelope, "OpenList login");
if (envelope.Data is not { ValueKind: JsonValueKind.Object } data
|| !data.TryGetProperty("token", out var tokenElement)
|| string.IsNullOrWhiteSpace(tokenElement.GetString()))
throw new InvalidOperationException("OpenList 登录响应未包含 token。");
var token = tokenElement.GetString()!;
_tokens[key] = new TokenCacheEntry(token, DateTimeOffset.UtcNow.AddMinutes(20));
return token;
}
finally
{
_loginGate.Release();
}
}
private void InvalidateToken(ConnectionInfo connection) =>
_tokens.TryRemove(BuildCacheKey(connection), out _);
private static ConnectionInfo Connect(OpenListSettings settings, string password) =>
new(NormalizeBaseUrl(settings?.Endpoint), settings?.UserName ?? string.Empty, password ?? string.Empty);
private static string BuildCacheKey(ConnectionInfo connection)
{
var raw = $"{connection.BaseUrl}\n{connection.Username}\n{connection.Password}";
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)));
}
private static string BuildRawUrl(string baseUrl, string path, string? rawUrl)
{
if (Uri.TryCreate(rawUrl, UriKind.Absolute, out var absolute)
&& absolute.Scheme is "http" or "https") return absolute.ToString();
if (!string.IsNullOrWhiteSpace(rawUrl)) return new Uri(new Uri(baseUrl + "/"), rawUrl).ToString();
return baseUrl + "/d" + StoragePath.Encode(path);
}
private static bool IsSameOrigin(string baseUrl, string rawUrl)
{
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var origin)
|| !Uri.TryCreate(rawUrl, UriKind.Absolute, out var target)) return false;
return string.Equals(origin.Scheme, target.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(origin.IdnHost, target.IdnHost, StringComparison.OrdinalIgnoreCase)
&& origin.Port == target.Port;
}
private static HttpRequestMessage JsonRequest(HttpMethod method, string path, object payload) =>
new(method, path) { Content = JsonContent.Create(payload, options: JsonOptions) };
private static async Task<ApiEnvelope> ReadEnvelopeAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
return new ApiEnvelope((int)response.StatusCode, response.ReasonPhrase ?? "Empty response", null);
try
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
var code = root.TryGetProperty("code", out var codeElement) && codeElement.TryGetInt32(out var parsed)
? parsed : (int)response.StatusCode;
var message = root.TryGetProperty("message", out var messageElement)
? messageElement.GetString() ?? body : body;
return new ApiEnvelope(code, message,
root.TryGetProperty("data", out var data) ? data.Clone() : null);
}
catch (JsonException ex)
{
throw new InvalidOperationException($"OpenList 返回了无效 JSONHTTP {(int)response.StatusCode}):{body}", ex);
}
}
private static bool IsMissing(ApiEnvelope envelope) =>
envelope.Code == 404 || ContainsAny(envelope.Message, "not found", "object not found", "no such file");
private static void EnsureSuccess(ApiEnvelope envelope, string operation)
{
if (envelope.Code != 200)
throw new InvalidOperationException($"{operation} failed with code {envelope.Code}: {envelope.Message}");
}
private static bool ContainsAny(string? value, params string[] candidates) =>
!string.IsNullOrWhiteSpace(value)
&& candidates.Any(candidate => value.Contains(candidate, StringComparison.OrdinalIgnoreCase));
private static string[] SplitPath(string path) =>
StoragePath.NormalizeRemote(path).Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private static string GetFileName(string path)
{
var normalized = StoragePath.NormalizeRemote(path);
var name = normalized[(normalized.LastIndexOf('/') + 1)..];
if (string.IsNullOrWhiteSpace(name)) throw new InvalidOperationException($"OpenList 路径 '{path}' 不包含文件名。");
return name;
}
private sealed record ConnectionInfo(string BaseUrl, string Username, string Password);
private sealed record TokenCacheEntry(string Token, DateTimeOffset ExpiresAt);
private sealed record CanonicalPathCacheEntry(string Path, DateTimeOffset ExpiresAt);
private sealed record OpenListDirectoryEntry(string Name, bool IsDirectory, long Size);
private sealed class OpenListDirectorySnapshot
{
public bool CanWrite { get; set; }
public List<OpenListDirectoryEntry> Entries { get; set; } = new();
}
private sealed record ApiEnvelope(int Code, string Message, JsonElement? Data);
}
public sealed record OpenListObjectInfo(
string Name,
long Size,
bool IsDirectory,
IReadOnlyDictionary<string, string> Hashes,
string? RawUrl);
public sealed record OpenListCopyResult(IReadOnlyList<string> TaskIds);
public sealed record OpenListDirectoryInspection(
string Path,
bool Exists,
int EntryCount,
int DirectoryCount,
int FileCount);
public sealed record OpenListTaskInfo(
string Id,
int State,
double Progress,
string Status,
string? Error);
}