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); }