using dy.net.model.dto; using dy.net.model.entity; using dy.net.repository; using dy.net.service; using dy.net.storage; using dy.net.Tests.TestInfrastructure; using Microsoft.AspNetCore.DataProtection; using Newtonsoft.Json; using SqlSugar; using MediaStorageType = dy.net.model.dto.StorageType; namespace dy.net.Tests; public class VideoTaskServiceTests { [Fact] public async Task TaskLifecycle_RefreshesStageCountsAndCompletesPartiallyFailed() { using var host = VideoTaskTestHost.Create(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "手动同步"); await host.Tasks.StartTaskAsync(task.Id); var succeeded = await host.Tasks.AddItemAsync(task.Id, "a", "cookie", "账号", VideoTypeEnum.dy_favorite, "成功", "作者"); var failed = await host.Tasks.AddItemAsync(task.Id, "b", "cookie", "账号", VideoTypeEnum.dy_favorite, "失败", "作者"); await host.Tasks.MarkSucceededAsync(succeeded.Id, 128, "video-a"); await host.Tasks.MarkFailedAsync(failed.Id, new InvalidOperationException("作品已下架"), VideoTaskErrorType.SourceUnavailable); await host.Tasks.MarkSkippedAsync(task.Id, "c", "cookie", "账号", VideoTypeEnum.dy_favorite, "跳过", "作者", VideoTaskSkipReason.AlreadyExists, "目标已存在"); await host.Tasks.CompleteTaskAsync(task.Id); var detail = await host.Tasks.GetTaskAsync(VideoTaskType.Sync, task.Id); Assert.Equal(VideoTaskStatus.PartiallyFailed, detail.Status); Assert.Equal(3, detail.TotalCount); Assert.Equal(1, detail.SuccessCount); Assert.Equal(1, detail.FailedCount); Assert.Equal(1, detail.SkippedCount); Assert.Equal(0, detail.PendingCount); Assert.Equal(0, detail.RunningCount); } [Fact] public async Task FailedImageItemWithoutSnapshot_DoesNotExposeBrokenRetry() { using var host = VideoTaskTestHost.Create(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "图文同步"); var item = await host.Tasks.AddItemAsync(task.Id, "image-aweme", "cookie", "账号", VideoTypeEnum.dy_follows, "图文作品", "作者"); await host.Tasks.MarkFailedAsync(item.Id, new IOException("图文合成失败"), VideoTaskErrorType.SourceUnavailable); var page = await host.Tasks.GetItemsAsync(VideoTaskType.Sync, task.Id, new VideoTaskItemPageRequest { PageIndex = 1, PageSize = 20, Stage = VideoTaskItemStage.Failed }); Assert.False(Assert.Single(page.Items).CanRetry); var error = await Assert.ThrowsAsync(() => host.Tasks.RetryItemAsync(VideoTaskType.Sync, task.Id, item.Id)); Assert.Contains("重新执行对应的同步任务", error.Message); } [Fact] public async Task RetryFailed_RetriesOnlyItemsWithRecoverySnapshots() { using var host = VideoTaskTestHost.Create(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "混合失败"); var image = await host.Tasks.AddItemAsync(task.Id, "image", "cookie", "账号", VideoTypeEnum.dy_follows, "图文", "作者"); var video = await host.Tasks.AddItemAsync(task.Id, "video", "cookie", "账号", VideoTypeEnum.dy_follows, "视频", "作者", "/follow/video.mp4", new[] { "https://media.test/video.mp4" }, new DouyinVideo { Id = "video-record", AwemeId = "video", CookieId = "cookie", VideoUrl = "https://media.test/video.mp4", VideoSavePath = "/follow/video.mp4", StorageType = MediaStorageType.WebDav }); await host.Tasks.MarkFailedAsync(image.Id, new IOException("图文失败"), VideoTaskErrorType.SourceUnavailable); await host.Tasks.MarkFailedAsync(video.Id, new IOException("写入失败"), VideoTaskErrorType.StorageUnavailable); await host.Tasks.RetryFailedAsync(VideoTaskType.Sync, task.Id); image = await host.Database.Queryable().InSingleAsync(image.Id); video = await host.Database.Queryable().InSingleAsync(video.Id); Assert.Equal(VideoTaskItemStage.Failed, image.Stage); Assert.False(image.WorkerManaged); Assert.Equal(VideoTaskItemStage.Pending, video.Stage); Assert.True(video.WorkerManaged); } [Fact] public async Task StorageFailure_PreservesSafeUnderlyingVerificationReason() { using var host = VideoTaskTestHost.Create(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "存储错误详情"); var item = await host.Tasks.AddItemAsync(task.Id, "aweme", "cookie", "账号", null, "作品", "作者"); var error = new MediaStorageException("WebDav 媒体写入失败:/follow/video.mp4", new IOException("WebDAV MOVE 后等待约 46.2 秒,最终文件仍返回旧长度或长度不一致:期望 20,实际 10")); await host.Tasks.MarkFailedAsync(item.Id, error); item = await host.Database.Queryable().InSingleAsync(item.Id); Assert.Equal(VideoTaskErrorType.StorageUnavailable, item.ErrorType); Assert.Contains("媒体写入失败", item.ErrorMessage); Assert.Contains("等待约 46.2 秒", item.ErrorMessage); Assert.Contains("期望 20,实际 10", item.ErrorMessage); } [Fact] public async Task CompleteTask_WithUnhandledTaskError_IsPersistedAsFailed() { using var host = VideoTaskTestHost.Create(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "定时同步"); await host.Tasks.StartTaskAsync(task.Id); await host.Tasks.CompleteTaskAsync(task.Id, "调度执行失败"); var detail = await host.Tasks.GetTaskAsync(VideoTaskType.Sync, task.Id); Assert.Equal(VideoTaskStatus.Failed, detail.Status); Assert.Equal("调度执行失败", detail.ErrorMessage); Assert.NotNull(detail.CompletedAt); } [Fact] public async Task CompleteTask_WithSuccessAndUnhandledTaskError_IsPersistedAsPartiallyFailed() { using var host = VideoTaskTestHost.Create(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "定时同步"); await host.Tasks.StartTaskAsync(task.Id); var item = await host.Tasks.AddItemAsync(task.Id, "aweme", "cookie", "账号", null, "作品", "作者"); await host.Tasks.MarkSucceededAsync(item.Id, 128, "video"); await host.Tasks.CompleteTaskAsync(task.Id, "后续处理失败"); var detail = await host.Tasks.GetTaskAsync(VideoTaskType.Sync, task.Id); Assert.Equal(VideoTaskStatus.PartiallyFailed, detail.Status); Assert.Equal("后续处理失败", detail.ErrorMessage); Assert.Equal(1, detail.SuccessCount); } [Fact] public async Task ScheduledEmptyStorageWait_ReusesSameTaskWithoutAffectingManualRuns() { using var host = VideoTaskTestHost.Create(); var first = await host.Tasks.CreateTaskAsync( VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "关注同步", VideoTypeEnum.dy_follows); await host.Tasks.BlockTaskForStorageAsync(first.Id, "存储不可用"); var reused = await host.Tasks.CreateTaskAsync( VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "关注同步", VideoTypeEnum.dy_follows); var otherType = await host.Tasks.CreateTaskAsync( VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "收藏同步", VideoTypeEnum.dy_collects); var manual = await host.Tasks.CreateTaskAsync( VideoTaskType.Sync, VideoTaskTrigger.Manual, "关注手动同步", VideoTypeEnum.dy_follows); Assert.Equal(first.Id, reused.Id); Assert.Equal(VideoTaskStatus.Queued, reused.Status); Assert.NotEqual(first.Id, otherType.Id); Assert.NotEqual(first.Id, manual.Id); Assert.Equal(3, await host.Database.Queryable().CountAsync()); } [Fact] public async Task TaskSummary_CountsAllTasksBeyondFirstHundred() { using var host = VideoTaskTestHost.Create(); var now = DateTime.Now; var completed = Enumerable.Range(0, 105).Select(index => new VideoDownloadTask { Id = $"completed-{index}", Type = VideoTaskType.Sync, Trigger = VideoTaskTrigger.Scheduled, Status = VideoTaskStatus.Completed, StorageType = MediaStorageType.Local, StorageFingerprint = "local", Title = "历史完成任务", CreatedAt = now.AddMinutes(-index), UpdatedAt = now.AddMinutes(-index), CompletedAt = now.AddMinutes(-index) }).ToList(); await host.Database.Insertable(completed).ExecuteCommandAsync(); var waiting = await host.Tasks.CreateTaskAsync( VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "等待存储", VideoTypeEnum.dy_follows); await host.Tasks.BlockTaskForStorageAsync(waiting.Id, "存储不可用"); var summary = await host.Tasks.GetSummaryAsync(); Assert.Equal(105, summary.Completed); Assert.Equal(1, summary.WaitingForStorage); } [Fact] public async Task SuccessfulStorageProbe_ClosesEmptyWaitsAndRequeuesRecoverableItems() { using var host = VideoTaskTestHost.Create(); await host.Database.Insertable(new DouyinCookie { Id = "storage-cookie", UserName = "账号", SavePath = host.Temporary.Path }).ExecuteCommandAsync(); var empty = await host.Tasks.CreateTaskAsync( VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "空等待", VideoTypeEnum.dy_follows); await host.Tasks.BlockTaskForStorageAsync(empty.Id, "存储不可用"); var recoverable = await host.Tasks.CreateTaskAsync( VideoTaskType.Sync, VideoTaskTrigger.Manual, "有条目等待", VideoTypeEnum.dy_favorite); var item = await host.Tasks.AddItemAsync( recoverable.Id, "waiting-item", "storage-cookie", "账号", VideoTypeEnum.dy_favorite, "等待作品", "作者", workerManaged: true); item.Stage = VideoTaskItemStage.WaitingForStorage; item.ErrorType = VideoTaskErrorType.StorageUnavailable; item.ErrorMessage = "存储不可用"; await host.Database.Updateable(item).ExecuteCommandAsync(); await host.Tasks.BlockTaskForStorageAsync(recoverable.Id, "存储不可用"); var probe = await host.Tasks.ProbeStorageAsync(CancellationToken.None); Assert.True(probe.Success, probe.Message); empty = await host.Database.Queryable().InSingleAsync(empty.Id); recoverable = await host.Database.Queryable().InSingleAsync(recoverable.Id); item = await host.Database.Queryable().InSingleAsync(item.Id); Assert.Equal(VideoTaskStatus.Cancelled, empty.Status); Assert.Contains("无条目的等待任务", empty.ErrorMessage); Assert.Equal(VideoTaskStatus.Queued, recoverable.Status); Assert.Equal(VideoTaskItemStage.Pending, item.Stage); Assert.Equal(VideoTaskErrorType.None, item.ErrorType); Assert.Null(item.ErrorMessage); } [Fact] public async Task ThreeConsecutiveStorageFailures_OpenCircuitForTasksOnSameTarget() { using var host = VideoTaskTestHost.Create(); var first = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "任务一"); var second = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "任务二"); await host.Tasks.StartTaskAsync(first.Id); await host.Tasks.StartTaskAsync(second.Id); var failures = new List(); for (var index = 0; index < 3; index++) failures.Add(await host.Tasks.AddItemAsync(first.Id, $"failed-{index}", "cookie", "账号", null, "失败", "作者")); var waiting = await host.Tasks.AddItemAsync(second.Id, "waiting", "cookie", "账号", null, "等待", "作者"); foreach (var item in failures) await host.Tasks.MarkFailedAsync(item.Id, new IOException("存储写入失败"), VideoTaskErrorType.StorageUnavailable); var health = await host.Tasks.GetStorageHealthAsync(); Assert.Equal(MediaStorageHealthStatus.Unavailable, health.Status); Assert.Equal(3, health.ConsecutiveFailures); Assert.False(await host.Tasks.IsStorageAvailableAsync()); Assert.Equal(VideoTaskStatus.WaitingForStorage, (await host.Database.Queryable().InSingleAsync(second.Id)).Status); Assert.Equal(VideoTaskItemStage.WaitingForStorage, (await host.Database.Queryable().InSingleAsync(waiting.Id)).Stage); await host.Database.Insertable(new DouyinCookie { Id = "changed", UserName = "新目录", SavePath = host.Temporary.Path }).ExecuteCommandAsync(); Assert.True(await host.Tasks.IsStorageAvailableAsync()); health = await host.Tasks.GetStorageHealthAsync(); Assert.Equal(MediaStorageHealthStatus.Healthy, health.Status); Assert.Equal(0, health.ConsecutiveFailures); } [Fact] public async Task StorageHealth_IsIsolatedByTaskTarget() { using var host = VideoTaskTestHost.Create(); var webDavTask = await host.Tasks.CreateTaskAsync(VideoTaskType.Redownload, VideoTaskTrigger.UserAction, "WebDAV 历史任务", storageType: MediaStorageType.WebDav); for (var index = 0; index < 3; index++) { var item = await host.Tasks.AddItemAsync(webDavTask.Id, $"webdav-{index}", null, null, null, "失败", null); await host.Tasks.MarkFailedAsync(item.Id, new IOException("WebDAV 写入失败"), VideoTaskErrorType.StorageUnavailable); } var currentLocalHealth = await host.Tasks.GetStorageHealthAsync(); var webDavHealth = await host.Database.Queryable() .Where(x => x.StorageType == MediaStorageType.WebDav).FirstAsync(); Assert.Equal(MediaStorageHealthStatus.Healthy, currentLocalHealth.Status); Assert.Equal(MediaStorageHealthStatus.Unavailable, webDavHealth.Status); Assert.Equal(3, webDavHealth.ConsecutiveFailures); var localTask = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "本地任务"); var localItem = await host.Tasks.AddItemAsync(localTask.Id, "local-success", null, null, null, "成功", null); await host.Tasks.MarkSucceededAsync(localItem.Id, 10); webDavHealth = await host.Database.Queryable() .Where(x => x.StorageType == MediaStorageType.WebDav).FirstAsync(); Assert.Equal(MediaStorageHealthStatus.Unavailable, webDavHealth.Status); Assert.Equal(3, webDavHealth.ConsecutiveFailures); } [Fact] public async Task SourceForbidden_DoesNotOpenStorageCircuit_AndThirdWorkStartsCooldown() { using var host = VideoTaskTestHost.Create(); await host.Database.Insertable(new DouyinCookie { Id = "cookie", UserName = "账号", Cookies = "sessionid=test" }) .ExecuteCommandAsync(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "403 测试"); var items = new List(); for (var index = 0; index < 3; index++) { var item = await host.Tasks.AddItemAsync(task.Id, $"aweme-{index}", "cookie", "账号", null, $"作品{index}", "作者"); items.Add(item); await host.Tasks.MarkDownloadFailedAsync(item.Id, "cookie", Forbidden()); } var storage = await host.Tasks.GetStorageHealthAsync(); var cookie = await host.Database.Queryable().InSingleAsync("cookie"); var third = await host.Database.Queryable().InSingleAsync(items[2].Id); Assert.Equal(MediaStorageHealthStatus.Healthy, storage.Status); Assert.Equal(0, storage.ConsecutiveFailures); Assert.Equal(3, cookie.ConsecutiveSourceForbidden); Assert.True(cookie.SourceCooldownUntil > DateTime.UtcNow); Assert.True(cookie.SourceProbePending); Assert.Equal(VideoTaskItemStage.WaitingForSource, third.Stage); Assert.Equal(VideoTaskStatus.WaitingForSource, (await host.Database.Queryable().InSingleAsync(task.Id)).Status); } [Fact] public async Task Cooldown_AllowsOnlyOldestProbe_AndSecondForbiddenRequiresAuthorization() { using var host = VideoTaskTestHost.Create(); await host.Database.Insertable(new DouyinCookie { Id = "cookie", UserName = "账号", Cookies = "sessionid=test" }) .ExecuteCommandAsync(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.ExclusionRestore, VideoTaskTrigger.UserAction, "来源恢复"); var items = new List(); for (var index = 0; index < 3; index++) { var item = await host.Tasks.AddItemAsync(task.Id, $"aweme-{index}", "cookie", "账号", null, $"作品{index}", "作者", workerManaged: true); items.Add(item); await host.Tasks.MarkDownloadFailedAsync(item.Id, "cookie", Forbidden()); } var additional = await host.Tasks.AddItemAsync(task.Id, "waiting-later", "cookie", "账号", null, "稍后作品", "作者", workerManaged: true); await host.Tasks.WaitItemForSourceAsync(additional.Id, "cookie", "等待探测"); var cookie = await host.Database.Queryable().InSingleAsync("cookie"); cookie.SourceCooldownUntil = DateTime.UtcNow.AddSeconds(-1); await host.Database.Updateable(cookie).ExecuteCommandAsync(); var laterDecision = await host.Tasks.TryAcquireSourceAsync("cookie", additional.Id); var oldestDecision = await host.Tasks.TryAcquireSourceAsync("cookie", items[0].Id); var concurrentDecision = await host.Tasks.TryAcquireSourceAsync("cookie", items[0].Id); Assert.False(laterDecision.Allowed); Assert.True(oldestDecision.Allowed); Assert.True(oldestDecision.IsProbe); Assert.False(concurrentDecision.Allowed); var disposition = await host.Tasks.MarkDownloadFailedAsync(items[0].Id, "cookie", Forbidden()); cookie = await host.Database.Queryable().InSingleAsync("cookie"); Assert.Equal(SourceFailureDisposition.RequiresAuthorization, disposition); Assert.True(cookie.SourceRequiresAuthorization); Assert.False(cookie.SourceProbePending); Assert.Null(cookie.SourceCooldownUntil); } [Fact] public async Task ProbeSuccess_ResetsPersistedHealthAndRequeuesWaitingItems() { using var host = VideoTaskTestHost.Create(); await host.Database.Insertable(new DouyinCookie { Id = "cookie", UserName = "账号", Cookies = "sessionid=test", ConsecutiveSourceForbidden = 3, SourceProbePending = true, SourceProbeInProgress = true, SourceCooldownUntil = DateTime.UtcNow.AddSeconds(-1), LastSourceStatusCode = 403, LastSourceError = "全部地址 403" }).ExecuteCommandAsync(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.ExclusionRestore, VideoTaskTrigger.UserAction, "恢复"); var item = await host.Tasks.AddItemAsync(task.Id, "aweme", "cookie", "账号", null, "作品", "作者", workerManaged: true); await host.Tasks.WaitItemForSourceAsync(item.Id, "cookie", "等待探测"); await host.Tasks.RecordSourceSuccessAsync("cookie"); var cookie = await host.Database.Queryable().InSingleAsync("cookie"); item = await host.Database.Queryable().InSingleAsync(item.Id); Assert.Equal(0, cookie.ConsecutiveSourceForbidden); Assert.False(cookie.SourceProbePending); Assert.False(cookie.SourceProbeInProgress); Assert.False(cookie.SourceRequiresAuthorization); Assert.Null(cookie.SourceCooldownUntil); Assert.Equal(VideoTaskItemStage.Pending, item.Stage); Assert.Equal(VideoTaskStatus.Queued, (await host.Database.Queryable().InSingleAsync(task.Id)).Status); } [Fact] public async Task Unauthorized_ImmediatelyRequiresAuthorization() { using var host = VideoTaskTestHost.Create(); await host.Database.Insertable(new DouyinCookie { Id = "cookie", UserName = "账号", Cookies = "old" }).ExecuteCommandAsync(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "401"); var item = await host.Tasks.AddItemAsync(task.Id, "aweme", "cookie", "账号", null, "作品", "作者"); var result = Forbidden(); result = new MediaDownloadResult { Success = false, FailureKind = MediaDownloadFailureKind.SourceUnauthorized, HttpStatusCode = 401, SourceHost = result.SourceHost, AttemptedUrlCount = 1, Message = "需要重新授权" }; var disposition = await host.Tasks.MarkDownloadFailedAsync(item.Id, "cookie", result); var cookie = await host.Database.Queryable().InSingleAsync("cookie"); item = await host.Database.Queryable().InSingleAsync(item.Id); Assert.Equal(SourceFailureDisposition.RequiresAuthorization, disposition); Assert.True(cookie.SourceRequiresAuthorization); Assert.Equal(VideoTaskItemStage.WaitingForSource, item.Stage); Assert.Equal(VideoTaskErrorType.CookieInvalid, item.ErrorType); } [Fact] public async Task NotFound_FailsOnlyItemWithoutBlockingAuthorization() { using var host = VideoTaskTestHost.Create(); await host.Database.Insertable(new DouyinCookie { Id = "cookie", UserName = "账号", Cookies = "valid" }).ExecuteCommandAsync(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "404"); var item = await host.Tasks.AddItemAsync(task.Id, "aweme", "cookie", "账号", null, "作品", "作者"); var result = new MediaDownloadResult { Success = false, FailureKind = MediaDownloadFailureKind.SourceNotFound, HttpStatusCode = 404, SourceHost = "v3.douyinvod.com", AttemptedUrlCount = 2, Message = "作品已下架" }; var disposition = await host.Tasks.MarkDownloadFailedAsync(item.Id, "cookie", result); var cookie = await host.Database.Queryable().InSingleAsync("cookie"); item = await host.Database.Queryable().InSingleAsync(item.Id); Assert.Equal(SourceFailureDisposition.Continue, disposition); Assert.False(cookie.SourceRequiresAuthorization); Assert.False(cookie.SourceProbePending); Assert.Equal(VideoTaskItemStage.Failed, item.Stage); Assert.Equal(VideoTaskErrorType.SourceUnavailable, item.ErrorType); } [Fact] public async Task RateLimited_UsesRetryAfterWithoutRequiringAuthorization() { using var host = VideoTaskTestHost.Create(); await host.Database.Insertable(new DouyinCookie { Id = "cookie", UserName = "账号", Cookies = "valid" }).ExecuteCommandAsync(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "429"); var item = await host.Tasks.AddItemAsync(task.Id, "aweme", "cookie", "账号", null, "作品", "作者"); var retryAt = DateTime.UtcNow.AddMinutes(3); var result = new MediaDownloadResult { Success = false, FailureKind = MediaDownloadFailureKind.SourceRateLimited, HttpStatusCode = 429, SourceHost = "v3.douyinvod.com", AttemptedUrlCount = 1, RetryAfter = retryAt, Message = "请求过于频繁" }; var disposition = await host.Tasks.MarkDownloadFailedAsync(item.Id, "cookie", result); var cookie = await host.Database.Queryable().InSingleAsync("cookie"); item = await host.Database.Queryable().InSingleAsync(item.Id); Assert.Equal(SourceFailureDisposition.WaitForSource, disposition); Assert.False(cookie.SourceRequiresAuthorization); Assert.True(cookie.SourceProbePending); Assert.True(cookie.SourceCooldownUntil >= retryAt.AddSeconds(-1)); Assert.Equal(VideoTaskErrorType.SourceRateLimited, item.ErrorType); } [Fact] public async Task Redownload_MixedStorageSelectionCreatesOneParentPerStorageTarget() { using var host = VideoTaskTestHost.Create(); var local = Video("local-video", "local-aweme", MediaStorageType.Local, Path.Combine(host.Temporary.Path, "local.mp4")); var remote = Video("remote-video", "remote-aweme", MediaStorageType.WebDav, "关注/remote.mp4"); await host.Database.Insertable(new[] { local, remote }).ExecuteCommandAsync(); var result = await host.CreateVideoService().ReDownloadViedoAsync(new ReDownViedoDto { Ids = new() { local.Id, remote.Id } }); Assert.True(result); var tasks = await host.Database.Queryable().OrderBy(x => x.StorageType).ToListAsync(); var jobs = await host.Database.Queryable().ToListAsync(); Assert.Equal(2, tasks.Count); Assert.Equal(2, jobs.Count); Assert.Contains(tasks, x => x.StorageType == MediaStorageType.Local && jobs.Any(j => j.TaskId == x.Id && j.VideoRecordId == local.Id)); Assert.Contains(tasks, x => x.StorageType == MediaStorageType.WebDav && jobs.Any(j => j.TaskId == x.Id && j.VideoRecordId == remote.Id)); } [Fact] public async Task RestartRecovery_RequeuesSnapshotItemsAndLeavesUnsafeItemsFailed() { using var host = VideoTaskTestHost.Create(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "重启任务"); await host.Tasks.StartTaskAsync(task.Id); var unsafePending = await host.Tasks.AddItemAsync(task.Id, "pending", "cookie", "账号", null, "等待", "作者"); var unsafeActive = await host.Tasks.AddItemAsync(task.Id, "unsafe-active", "cookie", "账号", null, "无快照", "作者"); await host.Tasks.SetItemStageAsync(unsafeActive.Id, VideoTaskItemStage.Downloading); var snapshot = Video("recoverable-record", "recoverable", MediaStorageType.Local, Path.Combine(host.Temporary.Path, "recoverable.mp4")); var recoverable = await host.Tasks.AddItemAsync(task.Id, snapshot.AwemeId, "cookie", "账号", null, "可恢复", "作者", snapshot.VideoSavePath, new[] { snapshot.VideoUrl }, snapshot); await host.Tasks.SetItemStageAsync(recoverable.Id, VideoTaskItemStage.Verifying); await host.Tasks.RecoverInterruptedAsync(); var recoveredTask = await host.Database.Queryable().InSingleAsync(task.Id); unsafePending = await host.Database.Queryable().InSingleAsync(unsafePending.Id); unsafeActive = await host.Database.Queryable().InSingleAsync(unsafeActive.Id); recoverable = await host.Database.Queryable().InSingleAsync(recoverable.Id); Assert.Equal(VideoTaskStatus.Queued, recoveredTask.Status); Assert.Contains("自动恢复 1 个条目", recoveredTask.ErrorMessage); Assert.Equal(VideoTaskItemStage.Pending, recoverable.Stage); Assert.True(recoverable.WorkerManaged); Assert.Equal(VideoTaskErrorType.None, recoverable.ErrorType); Assert.Null(recoverable.CompletedAt); Assert.All(new[] { unsafePending, unsafeActive }, item => { Assert.Equal(VideoTaskItemStage.Failed, item.Stage); Assert.Equal(VideoTaskErrorType.Interrupted, item.ErrorType); Assert.Contains("下一次同步", item.ErrorMessage); }); } [Fact] public async Task RestartRecovery_ClosesStaleEmptyQueueButKeepsQueuedTaskWithItems() { using var host = VideoTaskTestHost.Create(); var empty = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "空排队任务"); var withItem = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "有效排队任务"); await host.Tasks.AddItemAsync(withItem.Id, "pending", "cookie", "账号", null, "等待", "作者", workerManaged: true); var staleTime = DateTime.Now.AddMinutes(-30); empty.UpdatedAt = staleTime; withItem.UpdatedAt = staleTime; await host.Database.Updateable(new[] { empty, withItem }).ExecuteCommandAsync(); await host.Tasks.RecoverInterruptedAsync(); empty = await host.Database.Queryable().InSingleAsync(empty.Id); withItem = await host.Database.Queryable().InSingleAsync(withItem.Id); Assert.Equal(VideoTaskStatus.Cancelled, empty.Status); Assert.Contains("空任务", empty.ErrorMessage); Assert.NotNull(empty.CompletedAt); Assert.Equal(VideoTaskStatus.Queued, withItem.Status); } [Fact] public async Task RestartRecovery_EndsStaleWebDavSyncTasksAfterOpenListSwitch() { using var host = VideoTaskTestHost.Create(); var config = await host.Database.Queryable().FirstAsync(); config.StorageType = MediaStorageType.OpenList; await host.Database.Updateable(config).ExecuteCommandAsync(); var queued = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "旧 WebDAV 收藏同步", VideoTypeEnum.dy_favorite, MediaStorageType.WebDav); var item = await host.Tasks.AddItemAsync(queued.Id, "aweme", "cookie", "账号", VideoTypeEnum.dy_favorite, "作品", "作者", "/collect/old.mp4"); var waiting = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Scheduled, "旧 WebDAV 关注同步", VideoTypeEnum.dy_follows, MediaStorageType.WebDav); await host.Tasks.BlockTaskForStorageAsync(waiting.Id, "WebDAV 不可用"); var staleTime = DateTime.Now.AddMinutes(-30); queued.UpdatedAt = staleTime; waiting.UpdatedAt = staleTime; await host.Database.Updateable(new[] { queued, waiting }).ExecuteCommandAsync(); await host.Tasks.RecoverInterruptedAsync(); queued = await host.Database.Queryable().InSingleAsync(queued.Id); waiting = await host.Database.Queryable().InSingleAsync(waiting.Id); item = await host.Database.Queryable().InSingleAsync(item.Id); Assert.Equal(VideoTaskStatus.Cancelled, queued.Status); Assert.Equal(VideoTaskStatus.Cancelled, waiting.Status); Assert.True(queued.IsArchived); Assert.True(waiting.IsArchived); Assert.Equal(VideoTaskItemStage.Cancelled, item.Stage); Assert.Equal(VideoTaskErrorType.ConfigurationChanged, item.ErrorType); Assert.Contains("OpenList", queued.ErrorMessage); Assert.Equal(0, await host.Database.Queryable() .Where(x => x.Status == VideoTaskStatus.Queued).CountAsync()); Assert.Equal(0, await host.Database.Queryable() .Where(x => x.Status == VideoTaskStatus.WaitingForStorage).CountAsync()); var visible = await host.Tasks.GetTasksAsync(new VideoTaskPageRequest { PageIndex = 1, PageSize = 20 }); Assert.DoesNotContain(visible.Items, x => x.Id == queued.Id || x.Id == waiting.Id); } [Fact] public async Task RestartRecovery_ReleasesInterruptedSourceProbeForOldestWaitingItem() { using var host = VideoTaskTestHost.Create(); await host.Database.Insertable(new DouyinCookie { Id = "cookie", UserName = "账号", Cookies = "valid", SourceProbePending = true, SourceProbeInProgress = true, SourceCooldownUntil = DateTime.UtcNow.AddMinutes(-1) }).ExecuteCommandAsync(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.ExclusionRestore, VideoTaskTrigger.UserAction, "等待来源"); var item = await host.Tasks.AddItemAsync(task.Id, "aweme", "cookie", "账号", null, "作品", "作者", workerManaged: true); await host.Tasks.WaitItemForSourceAsync(item.Id, "cookie", "等待来源"); await host.Tasks.RecoverInterruptedAsync(); var decision = await host.Tasks.TryAcquireSourceAsync("cookie", item.Id); Assert.True(decision.Allowed); Assert.True(decision.IsProbe); } [Fact] public async Task UnexcludeOnly_RemovesAllDuplicateRowsWithoutCreatingTask() { using var host = VideoTaskTestHost.Create(); await host.Database.Insertable(new[] { Exclusion("one", "same-aweme"), Exclusion("duplicate", "same-aweme") }).ExecuteCommandAsync(); var service = new VideoExclusionService(host.Database, host.Tasks); var result = await service.UnexcludeAsync(new UnexcludeVideosRequest { Ids = new() { "one" } }); Assert.Equal(1, result.ReleasedCount); Assert.Null(result.TaskId); Assert.Equal(0, await host.Database.Queryable().CountAsync()); Assert.Equal(0, await host.Database.Queryable().CountAsync()); } [Fact] public async Task UnexcludeAndRestore_QueuesValidSnapshotsAndReportsOldIncompleteRows() { using var host = VideoTaskTestHost.Create(); var snapshot = new DouyinVideo { Id = "video-one", AwemeId = "good-aweme", CookieId = "cookie", ViedoType = VideoTypeEnum.dy_favorite, VideoTitle = "可恢复", VideoUrl = "https://example.test/video.mp4", VideoSavePath = Path.Combine(host.Temporary.Path, "good.mp4"), StorageType = MediaStorageType.Local }; var good = Exclusion("good", snapshot.AwemeId); good.RestoreSnapshotJson = JsonConvert.SerializeObject(snapshot); var old = Exclusion("old", "old-aweme"); await host.Database.Insertable(new[] { good, old }).ExecuteCommandAsync(); var service = new VideoExclusionService(host.Database, host.Tasks); var result = await service.UnexcludeAsync(new UnexcludeVideosRequest { Ids = new() { good.Id, old.Id }, CreateDownloadTask = true }); Assert.Equal(2, result.ReleasedCount); Assert.Equal(1, result.QueuedCount); Assert.Contains("old-aweme", result.CannotQueueIds); Assert.NotNull(result.TaskId); Assert.Equal(VideoTaskType.ExclusionRestore, (await host.Database.Queryable().InSingleAsync(result.TaskId)).Type); Assert.Single(await host.Database.Queryable().Where(x => x.TaskId == result.TaskId).ToListAsync()); } [Fact] public async Task CleanupHistory_UsesThirtyAndNinetyDayWindowsAndKeepsRollbackData() { using var host = VideoTaskTestHost.Create(); var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "历史"); var success = await host.Tasks.AddItemAsync(task.Id, "success", null, null, null, "成功", null); var failed = await host.Tasks.AddItemAsync(task.Id, "failed", null, null, null, "失败", null); success.Stage = VideoTaskItemStage.Succeeded; success.CompletedAt = DateTime.Now.AddDays(-31); failed.Stage = VideoTaskItemStage.Failed; failed.CompletedAt = DateTime.Now.AddDays(-89); await host.Database.Updateable(new[] { success, failed }).ExecuteCommandAsync(); var migration = new StorageMigrationTask { Id = "rollback-still-available", Status = StorageMigrationTaskStatus.Completed, Concurrency = 1, ConfigurationFingerprint = "fingerprint", CreatedAt = DateTime.Now.AddDays(-100), UpdatedAt = DateTime.Now.AddDays(-100) }; var migrationItem = new StorageMigrationItem { Id = "migration-item", TaskId = migration.Id, VideoId = "video", Stage = StorageMigrationItemStage.Succeeded, OldSnapshotJson = "{}", CreatedAt = migration.CreatedAt, UpdatedAt = migration.UpdatedAt }; await host.Database.Insertable(migration).ExecuteCommandAsync(); await host.Database.Insertable(migrationItem).ExecuteCommandAsync(); await host.Tasks.CleanupHistoryAsync(); Assert.Null(await host.Database.Queryable().InSingleAsync(success.Id)); Assert.NotNull(await host.Database.Queryable().InSingleAsync(failed.Id)); Assert.NotNull(await host.Database.Queryable().InSingleAsync(migrationItem.Id)); } [Fact] public async Task RemoveFailedMigrationRecords_DeduplicatesHistoryAndKeepsFilesAndExclusions() { using var host = VideoTaskTestHost.Create(); var oldFile = Path.Combine(host.Temporary.Path, "eligible-old.mp4"); await File.WriteAllTextAsync(oldFile, "keep this local fallback"); var eligible = Video("eligible", "aweme-eligible", MediaStorageType.Local, oldFile); var changed = Video("changed", "aweme-changed", MediaStorageType.Local, "/current/changed.mp4"); var excluded = Video("excluded", "aweme-excluded", MediaStorageType.Local, "/old/excluded.mp4"); var replacement = Video("replacement-new", "aweme-replacement", MediaStorageType.Local, "/current/replacement.mp4"); await host.Database.Insertable(new[] { eligible, changed, excluded, replacement }).ExecuteCommandAsync(); await host.Database.Insertable(Exclusion("excluded-entry", excluded.AwemeId)).ExecuteCommandAsync(); var first = MigrationTask("migration-first"); var second = MigrationTask("migration-second"); await host.Database.Insertable(new[] { first, second }).ExecuteCommandAsync(); var items = new[] { MigrationItem("eligible-first", first.Id, eligible, eligible.VideoSavePath), MigrationItem("eligible-second", second.Id, eligible, eligible.VideoSavePath), MigrationItem("already-missing", first.Id, Video("missing", "aweme-missing", MediaStorageType.Local, "/old/missing.mp4"), "/old/missing.mp4"), MigrationItem("changed", first.Id, changed, "/old/changed.mp4"), MigrationItem("replacement", first.Id, Video("replacement-old", replacement.AwemeId, MediaStorageType.Local, "/old/replacement.mp4"), "/old/replacement.mp4"), MigrationItem("excluded", first.Id, excluded, excluded.VideoSavePath), new StorageMigrationItem { Id = "invalid", TaskId = first.Id, VideoId = "invalid-video", Stage = StorageMigrationItemStage.Failed, OldSnapshotJson = "{}", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now } }; await host.Database.Insertable(items).ExecuteCommandAsync(); var preview = await host.Migration.PreviewFailedRecordRemovalAsync(); Assert.True(preview.CanExecute); Assert.Equal(7, preview.FailedItemCount); Assert.Equal(6, preview.DistinctVideoCount); Assert.Equal(1, preview.EligibleRecordCount); Assert.Equal(1, preview.AlreadyMissingCount); Assert.Equal(2, preview.ChangedRecordCount); Assert.Equal(1, preview.PermanentlyExcludedCount); Assert.Equal(1, preview.InvalidSnapshotCount); var result = await host.Migration.RemoveFailedRecordsAsync(new RemoveFailedMigrationRecordsRequest { ConfirmationToken = preview.ConfirmationToken }); Assert.Equal(1, result.DeletedRecordCount); Assert.Equal(1, result.AlreadyMissingCount); Assert.Equal(3, result.RemovedItemCount); Assert.Equal(2, result.AffectedTaskCount); Assert.Null(await host.Database.Queryable().InSingleAsync(eligible.Id)); Assert.NotNull(await host.Database.Queryable().InSingleAsync(changed.Id)); Assert.NotNull(await host.Database.Queryable().InSingleAsync(replacement.Id)); Assert.NotNull(await host.Database.Queryable().InSingleAsync(excluded.Id)); Assert.True(File.Exists(oldFile)); Assert.Single(await host.Database.Queryable().ToListAsync()); Assert.All(await host.Database.Queryable() .Where(x => x.Id == "eligible-first" || x.Id == "eligible-second" || x.Id == "already-missing").ToListAsync(), item => Assert.Equal(StorageMigrationItemStage.RecordRemoved, item.Stage)); Assert.Equal(StorageMigrationItemStage.Failed, (await host.Database.Queryable().InSingleAsync("changed")).Stage); Assert.Equal(2, (await host.Database.Queryable().InSingleAsync(first.Id)).RemovedCount); Assert.Equal(1, (await host.Database.Queryable().InSingleAsync(second.Id)).RemovedCount); Assert.Null(await host.CreateVideoService().GetByAwemeId(eligible.AwemeId)); await Assert.ThrowsAsync(() => host.Migration.RemoveFailedRecordsAsync( new RemoveFailedMigrationRecordsRequest { ConfirmationToken = preview.ConfirmationToken })); } [Fact] public async Task RemoveFailedMigrationRecords_IsBlockedWhileMigrationIsActive() { using var host = VideoTaskTestHost.Create(); var video = Video("eligible", "aweme", MediaStorageType.Local, "/old/video.mp4"); await host.Database.Insertable(video).ExecuteCommandAsync(); var historical = MigrationTask("historical"); var active = MigrationTask("active"); active.Status = StorageMigrationTaskStatus.Running; await host.Database.Insertable(new[] { historical, active }).ExecuteCommandAsync(); await host.Database.Insertable(MigrationItem("failed", historical.Id, video, video.VideoSavePath)).ExecuteCommandAsync(); var preview = await host.Migration.PreviewFailedRecordRemovalAsync(); Assert.False(preview.CanExecute); Assert.Equal(1, preview.ActiveMigrationCount); Assert.Null(preview.ConfirmationToken); Assert.Contains(preview.Errors, x => x.Contains("未结束") || x.Contains("运行")); } [Fact] public async Task FailedMigrationRecordPreview_BatchesLargeVideoSets() { using var host = VideoTaskTestHost.Create(); var task = MigrationTask("large-history"); await host.Database.Insertable(task).ExecuteCommandAsync(); var videos = Enumerable.Range(0, 1005).Select(index => Video($"video-{index}", $"aweme-{index}", MediaStorageType.Local, $"/old/{index}.mp4")).ToList(); var items = videos.Select((video, index) => MigrationItem($"item-{index}", task.Id, video, video.VideoSavePath)).ToList(); await host.Database.Insertable(videos).ExecuteCommandAsync(); await host.Database.Insertable(items).ExecuteCommandAsync(); var preview = await host.Migration.PreviewFailedRecordRemovalAsync(); Assert.True(preview.CanExecute); Assert.Equal(1005, preview.FailedItemCount); Assert.Equal(1005, preview.EligibleRecordCount); } [Fact] public async Task StorageInventory_ReportsRecordsOutsideCurrentStorage() { using var host = VideoTaskTestHost.Create(); var local = Video("local", "aweme-local", MediaStorageType.Local, "/old/local.mp4"); local.FileSize = 10; var remote = Video("remote", "aweme-remote", MediaStorageType.WebDav, "/new/remote.mp4"); remote.FileSize = 20; await host.Database.Insertable(new[] { local, remote }).ExecuteCommandAsync(); var inventory = await host.CreateVideoService().GetStorageInventoryAsync(MediaStorageType.WebDav); Assert.Equal(2, inventory.TotalRecordCount); Assert.Equal(1, inventory.LocalRecordCount); Assert.Equal(1, inventory.WebDavRecordCount); Assert.Equal(1, inventory.OtherStorageRecordCount); Assert.Equal(10, inventory.LocalDeclaredBytes); Assert.Equal(20, inventory.WebDavDeclaredBytes); Assert.False(inventory.AllRecordsOnCurrentStorage); } [Fact] public async Task ArchiveHistory_HidesSettledFailuresButKeepsTasksNeedingCleanup() { using var host = VideoTaskTestHost.Create(); var failedOnly = MigrationTask("failed-only"); var needsCleanup = MigrationTask("needs-cleanup"); failedOnly.TargetStorageType = MediaStorageType.OpenList; needsCleanup.TargetStorageType = MediaStorageType.OpenList; needsCleanup.Status = StorageMigrationTaskStatus.Completed; needsCleanup.SuccessCount = 2; await host.Database.Insertable(new[] { failedOnly, needsCleanup }).ExecuteCommandAsync(); var result = await host.Migration.ArchiveAllSettledAsync(); Assert.Equal(1, result.ArchivedCount); Assert.Equal(1, result.RequiresCleanupCount); Assert.True((await host.Database.Queryable().InSingleAsync(failedOnly.Id)).IsArchived); Assert.False((await host.Database.Queryable().InSingleAsync(needsCleanup.Id)).IsArchived); var visible = await host.Tasks.GetTasksAsync(new VideoTaskPageRequest { PageIndex = 1, PageSize = 20 }); Assert.DoesNotContain(visible.Items, x => x.Id == failedOnly.Id); Assert.Contains(visible.Items, x => x.Id == needsCleanup.Id); } [Fact] public async Task StorageReplacementCleanup_DeletesOnlyUnreferencedLocalArtifactsAfterRemoteCommit() { using var host = VideoTaskTestHost.Create(); var root = Path.Combine(host.Temporary.Path, "media"); var folder = Path.Combine(root, "author", "video"); Directory.CreateDirectory(folder); var main = Path.Combine(folder, "video.mp4"); var cover = Path.Combine(folder, "video-poster.jpg"); var nfo = Path.Combine(folder, "video.nfo"); var avatar = Path.Combine(root, "author", "avatar.jpg"); await File.WriteAllTextAsync(main, "old-main"); await File.WriteAllTextAsync(cover, "old-cover"); await File.WriteAllTextAsync(nfo, "old-nfo"); await File.WriteAllTextAsync(avatar, "shared-avatar"); var cookie = new DouyinCookie { Id = "cookie", UserName = "账号", SavePath = root }; await host.Database.Insertable(cookie).ExecuteCommandAsync(); var snapshot = Video("replaced", "aweme-replaced", MediaStorageType.Local, main); snapshot.VideoCoverSavePath = cover; snapshot.AuthorAvatar = avatar; var current = Video(snapshot.Id, snapshot.AwemeId, MediaStorageType.WebDav, "/target/video.mp4"); current.VideoCoverSavePath = "/target/video-poster.jpg"; var shared = Video("shared", "aweme-shared", MediaStorageType.Local, Path.Combine(root, "other.mp4")); shared.AuthorAvatar = avatar; await host.Database.Insertable(new[] { current, shared }).ExecuteCommandAsync(); var settings = await host.Settings.BuildCandidateAsync(new WebDavTestRequest { Endpoint = "http://storage.test/dav", BasePath = "/dysync", UserName = "user", Password = "password" }); await host.Settings.SaveAsync(settings, true, "ok"); var remote = System.Text.Encoding.UTF8.GetBytes("new-main"); await using (var content = new MemoryStream(remote, false)) await host.WebDav.WriteAsync(current.VideoSavePath, content, remote.Length, "video/mp4"); var deleted = await host.CreateVideoService().CleanupSupersededLocalArtifactsAsync(snapshot, current.VideoSavePath); Assert.Equal(3, deleted); Assert.False(File.Exists(main)); Assert.False(File.Exists(cover)); Assert.False(File.Exists(nfo)); Assert.True(File.Exists(avatar)); Assert.True(await host.WebDav.ExistsAsync(current.VideoSavePath)); } [Fact] public async Task CleanupRetry_RevalidatesRemoteAndClearsCleanupWarning() { using var host = VideoTaskTestHost.Create(); var root = Path.Combine(host.Temporary.Path, "cleanup-retry"); Directory.CreateDirectory(root); var main = Path.Combine(root, "video.mp4"); await File.WriteAllTextAsync(main, "old-main"); await host.Database.Insertable(new DouyinCookie { Id = "cookie", UserName = "账号", SavePath = root }) .ExecuteCommandAsync(); var snapshot = Video("replaced", "aweme-cleanup-retry", MediaStorageType.Local, main); var current = Video(snapshot.Id, snapshot.AwemeId, MediaStorageType.WebDav, "/target/retry.mp4"); await host.Database.Insertable(current).ExecuteCommandAsync(); var settings = await host.Settings.BuildCandidateAsync(new WebDavTestRequest { Endpoint = "http://storage.test/dav", BasePath = "/dysync", UserName = "user", Password = "password" }); await host.Settings.SaveAsync(settings, true, "ok"); var remote = System.Text.Encoding.UTF8.GetBytes("new-main"); await using (var content = new MemoryStream(remote, false)) await host.WebDav.WriteAsync(current.VideoSavePath, content, remote.Length, "video/mp4"); current.SupersededStorageSnapshot = snapshot; var task = await host.Tasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, "清理重试", storageType: MediaStorageType.WebDav); var item = await host.Tasks.AddItemAsync(task.Id, current.AwemeId, "cookie", "账号", current.ViedoType, current.VideoTitle, current.Author, current.VideoSavePath, new[] { current.VideoUrl }, current, remote.Length); await host.Tasks.MarkSucceededAsync(item.Id, remote.Length, current.Id, "已切换到 WebDav,但旧本地文件清理失败,可稍后重试清理。"); var legacyDto = Assert.Single((await host.Tasks.GetItemsAsync(VideoTaskType.Sync, task.Id, new VideoTaskItemPageRequest { PageIndex = 1, PageSize = 20 })).Items); Assert.True(legacyDto.CanRetryCleanup); var deleted = await host.CreateVideoService().RetryTaskItemLocalCleanupAsync(task.Id, item.Id); item = await host.Database.Queryable().InSingleAsync(item.Id); Assert.Equal(1, deleted); Assert.False(File.Exists(main)); Assert.Equal(VideoTaskItemStage.Succeeded, item.Stage); Assert.False(item.CleanupPending); Assert.Null(item.CleanupError); Assert.Null(item.WarningMessage); var dto = Assert.Single((await host.Tasks.GetItemsAsync(VideoTaskType.Sync, task.Id, new VideoTaskItemPageRequest { PageIndex = 1, PageSize = 20 })).Items); Assert.False(dto.CanRetryCleanup); } private static DouyinVideoDelete Exclusion(string id, string awemeId) => new() { Id = id, ViedoId = awemeId, VideoTitle = awemeId, DeleteTime = DateTime.Now }; private static StorageMigrationTask MigrationTask(string id) => new() { Id = id, Status = StorageMigrationTaskStatus.PartiallyFailed, Concurrency = 1, ConfigurationFingerprint = "fingerprint", CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; private static StorageMigrationItem MigrationItem(string id, string taskId, DouyinVideo snapshot, string oldPath) => new() { Id = id, TaskId = taskId, VideoId = snapshot.Id, Stage = StorageMigrationItemStage.Failed, OldVideoPath = oldPath, OldSnapshotJson = JsonConvert.SerializeObject(new DouyinVideo { Id = snapshot.Id, AwemeId = snapshot.AwemeId, StorageType = MediaStorageType.Local, VideoSavePath = oldPath, CookieId = snapshot.CookieId, ViedoType = snapshot.ViedoType }), CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now }; private static DouyinVideo Video(string id, string awemeId, MediaStorageType storageType, string path) => new() { Id = id, AwemeId = awemeId, CookieId = "cookie", ViedoType = VideoTypeEnum.dy_follows, VideoTitle = id, Author = "同名博主", AuthorId = id + "-author", VideoUrl = "https://example.test/video.mp4", VideoSavePath = path, StorageType = storageType, CreateTime = DateTime.Now, SyncTime = DateTime.Now }; private static MediaDownloadResult Forbidden() => new() { Success = false, ActualSavePath = "/target/video.mp4", FailureKind = MediaDownloadFailureKind.SourceForbidden, HttpStatusCode = 403, SourceHost = "v3.douyinvod.com", AttemptedUrlCount = 4, Message = "全部 4 个候选媒体地址均返回 403。" }; private sealed class VideoTaskTestHost : IDisposable { private readonly StubHttpClientFactory _clientFactory; private VideoTaskTestHost(TemporaryDirectory temporary, SqlSugarClient database, StubHttpClientFactory clientFactory, WebDavSettingsService settings, WebDavMediaStorage webDav, StorageMigrationService migration, VideoTaskService tasks) { Temporary = temporary; Database = database; _clientFactory = clientFactory; Settings = settings; WebDav = webDav; Migration = migration; Tasks = tasks; } public TemporaryDirectory Temporary { get; } public SqlSugarClient Database { get; } public WebDavMediaStorage WebDav { get; } public OpenListMediaStorage OpenList { get; private set; } public WebDavSettingsService Settings { get; } public StorageMigrationService Migration { get; } public VideoTaskService Tasks { get; } public DouyinVideoService CreateVideoService() => new( new DouyinVideoRepository(Database), new DouyinCookieRepository(Database), Database, new MediaStorageRouter(new LocalMediaStorage(), WebDav, OpenList), Tasks); public static VideoTaskTestHost Create() { var temporary = new TemporaryDirectory(); try { var database = new SqlSugarClient(new ConnectionConfig { ConnectionString = $"DataSource={Path.Combine(temporary.Path, "tasks.sqlite")}", DbType = DbType.Sqlite, InitKeyType = InitKeyType.Attribute, IsAutoCloseConnection = true }); database.CodeFirst.InitTables(new[] { typeof(AppConfig), typeof(DouyinCookie), typeof(WebDavSettings), typeof(VideoDownloadTask), typeof(VideoDownloadTaskItem), typeof(MediaStorageHealth), typeof(StorageMigrationTask), typeof(StorageMigrationItem), typeof(DouyinVideoDelete), typeof(DouyinVideo), typeof(DouyinReDownload), typeof(OpenListSettings), typeof(OpenListTransferJob), typeof(OpenListDirectoryRepairTask), typeof(OpenListDirectoryRepairItem) }); database.Insertable(new AppConfig { Id = "config", StorageType = MediaStorageType.Local, FollowedTitleTemplate = string.Empty, FollowedTitleSeparator = string.Empty, FullFollowedTitleTemplate = string.Empty }).ExecuteCommand(); var provider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(temporary.Path, "keys")), builder => builder.SetApplicationName("dysync.net")); var clientFactory = new StubHttpClientFactory(new InMemoryWebDavHandler("user", "password")); var settings = new WebDavSettingsService(database, provider); var webDav = new WebDavMediaStorage(clientFactory, settings); var common = new DouyinCommonService(database); var openListSettings = new OpenListSettingsService(database, provider, settings); var openListClient = new OpenListClient(clientFactory); var transfers = new OpenListTransferService(database, openListSettings, openListClient); var openList = new OpenListMediaStorage(openListSettings, openListClient, transfers); var migration = new StorageMigrationService(database, common, openListSettings, openList); var repair = new OpenListDirectoryRepairService(database, common, openListSettings, openListClient); var tasks = new VideoTaskService(database, common, settings, webDav, openListSettings, openList, migration, repair); var host = new VideoTaskTestHost(temporary, database, clientFactory, settings, webDav, migration, tasks) { OpenList = openList }; return host; } catch { temporary.Dispose(); throw; } } public void Dispose() { Database.Dispose(); _clientFactory.Dispose(); Temporary.Dispose(); } } }