diff --git a/src/LiveRecorder.Infrastructure/Services/OpenListClient.cs b/src/LiveRecorder.Infrastructure/Services/OpenListClient.cs index 8c7992b..2b16d9a 100644 --- a/src/LiveRecorder.Infrastructure/Services/OpenListClient.cs +++ b/src/LiveRecorder.Infrastructure/Services/OpenListClient.cs @@ -214,7 +214,11 @@ public sealed class OpenListClient : IOpenListClient // Cloud drivers can complete a server-side copy without invalidating // OpenList's directory cache. Refreshing the parent also makes files // written directly into a local mount visible before they are copied. - await RefreshDirectoryAsync(connection, GetDirectoryName(path), cancellationToken); + if (!await TryRefreshDirectoryAsync(connection, GetDirectoryName(path), cancellationToken)) + { + return null; + } + return await TryGetObjectCoreAsync(connection, path, cancellationToken); } @@ -267,7 +271,7 @@ public sealed class OpenListClient : IOpenListClient return new OpenListObjectInfo(name, size, isDirectory, hashes); } - private async Task RefreshDirectoryAsync( + private async Task TryRefreshDirectoryAsync( OpenListConnectionRequest connection, string path, CancellationToken cancellationToken) @@ -279,7 +283,22 @@ public sealed class OpenListClient : IOpenListClient "/api/fs/list", new { path, password = string.Empty, refresh = true, page = 1, per_page = 0 }), cancellationToken); + + if (envelope.Code == 200) + { + return true; + } + + // OpenList commonly reports a missing directory as code 500 instead + // of 404. A caller probing a not-yet-created target should still see + // that as a normal cache miss. + if (envelope.Code == 404 || ContainsAny(envelope.Message, "not found", "object not found", "no such file")) + { + return false; + } + EnsureSuccess(envelope, $"OpenList refresh '{path}'"); + return true; } public async Task CopyFileAsync( diff --git a/src/LiveRecorder.Infrastructure/Services/OpenListUploadQueueService.cs b/src/LiveRecorder.Infrastructure/Services/OpenListUploadQueueService.cs index 2ff182d..78652f4 100644 --- a/src/LiveRecorder.Infrastructure/Services/OpenListUploadQueueService.cs +++ b/src/LiveRecorder.Infrastructure/Services/OpenListUploadQueueService.cs @@ -375,6 +375,11 @@ public sealed class OpenListUploadQueueService return; } + // OpenList returns code 500 when refreshing a directory that does not + // exist yet. Create the complete streamer/date hierarchy before the + // first target probe so a new recording path can be uploaded normally. + await _openListClient.EnsureDirectoryAsync(connection, GetDirectoryName(targetPath), cancellationToken); + var existingTarget = await VerifyTargetAsync(connection, targetPath, localPath, expectedSize, cancellationToken); if (existingTarget == TargetVerification.Match) { @@ -402,7 +407,6 @@ public sealed class OpenListUploadQueueService throw new InvalidOperationException($"OpenList 源文件大小不一致:期望 {expectedSize},实际 {sourceObject.Size},路径 {sourcePath}"); } - await _openListClient.EnsureDirectoryAsync(connection, GetDirectoryName(targetPath), cancellationToken); var copyResult = await _openListClient.CopyFileAsync(connection, sourcePath, targetPath, cancellationToken); if (copyResult.TaskIds.Count == 0) { diff --git a/tests/LiveRecorder.Tests/OpenListUploadTests.cs b/tests/LiveRecorder.Tests/OpenListUploadTests.cs index 5fbf7a4..b693dee 100644 --- a/tests/LiveRecorder.Tests/OpenListUploadTests.cs +++ b/tests/LiveRecorder.Tests/OpenListUploadTests.cs @@ -123,6 +123,38 @@ public sealed class OpenListUploadTests Assert.Equal("/archive/主播", refreshedPath); } + [Fact] + public async Task TryGetObject_TreatsMissingParentDuringRefreshAsCacheMiss() + { + var getCount = 0; + var refreshedPath = string.Empty; + var handler = new StubHttpMessageHandler(async request => + { + if (request.RequestUri!.AbsolutePath.EndsWith("/api/auth/login", StringComparison.Ordinal)) + { + return JsonResponse("""{"code":200,"message":"success","data":{"token":"test-token"}}"""); + } + + var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement; + if (request.RequestUri.AbsolutePath.EndsWith("/api/fs/list", StringComparison.Ordinal)) + { + refreshedPath = body.GetProperty("path").GetString(); + return JsonResponse("""{"code":500,"message":"failed get objs: failed get dir: object not found","data":null}"""); + } + + Assert.EndsWith("/api/fs/get", request.RequestUri.AbsolutePath, StringComparison.Ordinal); + getCount++; + return JsonResponse("""{"code":500,"message":"object not found","data":null}"""); + }); + var client = CreateClient(handler); + + var result = await client.TryGetObjectAsync(Connection(), "/archive/新主播/2026-08-01/segment.mp4"); + + Assert.Null(result); + Assert.Equal(1, getCount); + Assert.Equal("/archive/新主播/2026-08-01", refreshedPath); + } + [Fact] public async Task CopyAndTaskPolling_UseOpenListV423Contract() { @@ -313,6 +345,10 @@ public sealed class OpenListUploadTests Assert.Equal(twoGiB, job.VideoSizeBytes); Assert.True(await fixture.Queue.ProcessNextAsync()); Assert.Equal([(job.SourceVideoPath, job.TargetVideoPath)], fixture.OpenList.CopyRequests); + Assert.Equal(["/destination/Douyin/2026/08/01/主播"], fixture.OpenList.EnsuredDirectories); + Assert.True( + fixture.OpenList.Operations.IndexOf("ensure:/destination/Douyin/2026/08/01/主播") < + fixture.OpenList.Operations.IndexOf($"get:{job.TargetVideoPath}")); } [Fact] @@ -575,6 +611,10 @@ public sealed class OpenListUploadTests public List<(string Source, string Target)> CopyRequests { get; } = []; + public List EnsuredDirectories { get; } = []; + + public List Operations { get; } = []; + public OpenListCopyResult NextCopyResult { get; set; } = new([]); public Task TestConnectionAsync( @@ -588,13 +628,21 @@ public sealed class OpenListUploadTests public Task EnsureDirectoryAsync( OpenListConnectionRequest connection, string path, - CancellationToken cancellationToken = default) => Task.CompletedTask; + CancellationToken cancellationToken = default) + { + EnsuredDirectories.Add(path); + Operations.Add($"ensure:{path}"); + return Task.CompletedTask; + } public Task TryGetObjectAsync( OpenListConnectionRequest connection, string path, - CancellationToken cancellationToken = default) => - Task.FromResult(Objects.GetValueOrDefault(path)); + CancellationToken cancellationToken = default) + { + Operations.Add($"get:{path}"); + return Task.FromResult(Objects.GetValueOrDefault(path)); + } public Task CopyFileAsync( OpenListConnectionRequest connection, @@ -602,6 +650,7 @@ public sealed class OpenListUploadTests string targetPath, CancellationToken cancellationToken = default) { + Operations.Add($"copy:{sourcePath}->{targetPath}"); CopyRequests.Add((sourcePath, targetPath)); return Task.FromResult(NextCopyResult); }