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
{
///
/// OpenList 原生 API 客户端。所有需要认证的请求共用 token 缓存,401 时只重新登录一次。
///
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 _delayAsync;
private readonly ConcurrentDictionary _tokens = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _canonicalDirectories = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary _directoryGates = new(StringComparer.Ordinal);
private readonly SemaphoreSlim _loginGate = new(1, 1);
public OpenListClient(IHttpClientFactory httpClientFactory)
: this(httpClientFactory, Task.Delay)
{
}
internal OpenListClient(
IHttpClientFactory httpClientFactory,
Func delayAsync)
{
_httpClientFactory = httpClientFactory;
_delayAsync = delayAsync ?? Task.Delay;
}
public async Task 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 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 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);
}
}
///
/// 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.
///
public async Task 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 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);
}
///
/// 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).
///
public async Task 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 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 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();
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 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 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 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 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(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 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 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 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 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 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 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 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 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 SendAuthorizedAsync(
ConnectionInfo connection,
Func 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 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 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 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 Hashes,
string? RawUrl);
public sealed record OpenListCopyResult(IReadOnlyList 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);
}