using System.Security.Cryptography; using System.Text; using dy.net.extension; using dy.net.model.dto; using dy.net.model.entity; using dy.net.storage; using Newtonsoft.Json; using SqlSugar; using MediaStorageType = dy.net.model.dto.StorageType; namespace dy.net.service { public class VideoTaskService { private readonly ISqlSugarClient _db; private readonly DouyinCommonService _common; private readonly WebDavSettingsService _webDavSettings; private readonly WebDavMediaStorage _webDav; private readonly OpenListSettingsService _openListSettings; private readonly OpenListMediaStorage _openList; private readonly StorageMigrationService _migration; private readonly OpenListDirectoryRepairService _directoryRepair; public VideoTaskService( ISqlSugarClient db, DouyinCommonService common, WebDavSettingsService webDavSettings, WebDavMediaStorage webDav, OpenListSettingsService openListSettings, OpenListMediaStorage openList, StorageMigrationService migration, OpenListDirectoryRepairService directoryRepair) { _db = db; _common = common; _webDavSettings = webDavSettings; _webDav = webDav; _openListSettings = openListSettings; _openList = openList; _migration = migration; _directoryRepair = directoryRepair; } public async Task CreateTaskAsync( VideoTaskType type, VideoTaskTrigger trigger, string title, VideoTypeEnum? videoType = null, MediaStorageType? storageType = null) { var resolvedStorage = storageType ?? (_common.GetConfig()?.StorageType ?? MediaStorageType.Local); var storageFingerprint = await GetCurrentFingerprintAsync(resolvedStorage); if (trigger == VideoTaskTrigger.Scheduled) { var waiting = await _db.Queryable() .Where(x => x.Type == type && x.Trigger == VideoTaskTrigger.Scheduled && x.VideoType == videoType && x.StorageType == resolvedStorage && x.StorageFingerprint == storageFingerprint && x.Status == VideoTaskStatus.WaitingForStorage) .OrderBy(x => x.UpdatedAt, OrderByType.Desc) .FirstAsync(); if (waiting != null && !await _db.Queryable() .Where(x => x.TaskId == waiting.Id).AnyAsync()) { waiting.Status = VideoTaskStatus.Queued; waiting.ErrorMessage = null; waiting.CompletedAt = null; waiting.UpdatedAt = DateTime.Now; await _db.Updateable(waiting).UpdateColumns(x => new { x.Status, x.ErrorMessage, x.CompletedAt, x.UpdatedAt }).ExecuteCommandAsync(); return waiting; } } var now = DateTime.Now; var task = new VideoDownloadTask { Id = Guid.NewGuid().ToString("N"), Type = type, Trigger = trigger, Status = VideoTaskStatus.Queued, VideoType = videoType, StorageType = resolvedStorage, Title = string.IsNullOrWhiteSpace(title) ? TaskTypeText(type) : title, StorageFingerprint = storageFingerprint, CreatedAt = now, UpdatedAt = now }; await _db.Insertable(task).ExecuteCommandAsync(); return task; } public async Task StartTaskAsync(string taskId) { var now = DateTime.Now; await _db.Updateable() .SetColumns(x => new VideoDownloadTask { Status = VideoTaskStatus.Running, StartedAt = now, UpdatedAt = now, ErrorMessage = null }) .Where(x => x.Id == taskId && (x.Status == VideoTaskStatus.Queued || x.Status == VideoTaskStatus.Interrupted || x.Status == VideoTaskStatus.WaitingForSource)) .ExecuteCommandAsync(); } public async Task BlockTaskForStorageAsync(string taskId, string message) { await _db.Updateable() .SetColumns(x => new VideoDownloadTask { Status = VideoTaskStatus.WaitingForStorage, ErrorMessage = message, UpdatedAt = DateTime.Now }).Where(x => x.Id == taskId).ExecuteCommandAsync(); } public async Task AddItemAsync( string taskId, string awemeId, string cookieId, string cookieName, VideoTypeEnum? videoType, string title, string author, string targetPath = null, IEnumerable sourceUrls = null, DouyinVideo retrySnapshot = null, long expectedLength = 0, bool workerManaged = false) { var existing = await _db.Queryable() .Where(x => x.TaskId == taskId && x.AwemeId == awemeId).FirstAsync(); if (existing != null) return existing; var now = DateTime.Now; var item = new VideoDownloadTaskItem { Id = Guid.NewGuid().ToString("N"), TaskId = taskId, AwemeId = awemeId, CookieId = cookieId, CookieName = cookieName, VideoType = videoType, VideoTitle = title, Author = author, TargetPath = targetPath, ExpectedLength = Math.Max(0, expectedLength), WorkerManaged = workerManaged, SourceUrlsJson = sourceUrls == null ? null : JsonConvert.SerializeObject(sourceUrls.Where(x => !string.IsNullOrWhiteSpace(x)).Distinct()), RetrySnapshotJson = retrySnapshot == null ? null : JsonConvert.SerializeObject(retrySnapshot), Stage = VideoTaskItemStage.Pending, CreatedAt = now, UpdatedAt = now }; await _db.Insertable(item).ExecuteCommandAsync(); await RefreshCountsAsync(taskId); return item; } public async Task UpdateItemPlanAsync(string itemId, string targetPath, DouyinVideo retrySnapshot, long expectedLength = 0) { var item = await _db.Queryable().InSingleAsync(itemId); if (item == null) return; item.TargetPath = targetPath; item.ExpectedLength = Math.Max(item.ExpectedLength, expectedLength); if (retrySnapshot != null) item.RetrySnapshotJson = JsonConvert.SerializeObject(retrySnapshot); item.UpdatedAt = DateTime.Now; await _db.Updateable(item).ExecuteCommandAsync(); } public async Task SetItemStageAsync(string itemId, VideoTaskItemStage stage, string currentFile = null) { var item = await RequireItemAsync(itemId); item.Stage = stage; item.UpdatedAt = DateTime.Now; if (stage == VideoTaskItemStage.Downloading) item.Attempts++; await _db.Updateable(item).ExecuteCommandAsync(); await _db.Updateable() .SetColumns(x => new VideoDownloadTask { CurrentFile = currentFile ?? item.TargetPath, UpdatedAt = DateTime.Now }) .Where(x => x.Id == item.TaskId).ExecuteCommandAsync(); await RefreshCountsAsync(item.TaskId); } public async Task MarkSucceededAsync( string itemId, long actualLength, string videoId = null, string warning = null, bool cleanupPending = false, string cleanupError = null) { var item = await RequireItemAsync(itemId); item.Stage = string.IsNullOrWhiteSpace(warning) ? VideoTaskItemStage.Succeeded : VideoTaskItemStage.SucceededWithWarnings; item.ActualLength = Math.Max(0, actualLength); item.VideoId = videoId ?? item.VideoId; item.WarningMessage = warning; item.CleanupPending = cleanupPending; item.CleanupError = cleanupError; item.ErrorMessage = null; item.ErrorType = VideoTaskErrorType.None; item.CompletedAt = DateTime.Now; item.UpdatedAt = DateTime.Now; await _db.Updateable(item).ExecuteCommandAsync(); await RecordStorageSuccessAsync(item.TaskId); await RefreshCountsAsync(item.TaskId); } public async Task MarkSkippedAsync( string taskId, string awemeId, string cookieId, string cookieName, VideoTypeEnum videoType, string title, string author, VideoTaskSkipReason reason, string message, string exclusionId = null) { var item = await AddItemAsync(taskId, awemeId, cookieId, cookieName, videoType, title, author); item.Stage = VideoTaskItemStage.Skipped; item.SkipReason = reason; item.ErrorType = reason == VideoTaskSkipReason.PermanentlyExcluded ? VideoTaskErrorType.Excluded : VideoTaskErrorType.None; item.ErrorMessage = message; item.ExclusionId = exclusionId; item.CompletedAt = DateTime.Now; item.UpdatedAt = DateTime.Now; await _db.Updateable(item).ExecuteCommandAsync(); await RefreshCountsAsync(taskId); } public async Task MarkFailedAsync(string itemId, Exception error, VideoTaskErrorType? explicitType = null) { var item = await RequireItemAsync(itemId); var errorType = explicitType ?? Classify(error); item.Stage = VideoTaskItemStage.Failed; item.ErrorType = errorType; item.ErrorMessage = DescribeError(error); item.CompletedAt = DateTime.Now; item.UpdatedAt = DateTime.Now; await _db.Updateable(item).ExecuteCommandAsync(); if (errorType == VideoTaskErrorType.StorageUnavailable) await RecordStorageFailureAsync(item.TaskId, item.ErrorMessage); await RefreshCountsAsync(item.TaskId); } public async Task MarkDownloadFailedAsync( string itemId, string cookieId, MediaDownloadResult result) { if (result == null || result.Success) return SourceFailureDisposition.Continue; var item = await RequireItemAsync(itemId); item.SourceHost = result.SourceHost; item.HttpStatusCode = result.HttpStatusCode; item.RetryAfter = result.RetryAfter; item.UpdatedAt = DateTime.Now; await _db.Updateable(item).ExecuteCommandAsync(); if (result.FailureKind == MediaDownloadFailureKind.StorageUnavailable) { await MarkFailedAsync(itemId, result.ToException(), VideoTaskErrorType.StorageUnavailable); return SourceFailureDisposition.Continue; } if (result.FailureKind == MediaDownloadFailureKind.IntegrityCheckFailed) { await MarkFailedAsync(itemId, result.ToException(), VideoTaskErrorType.IntegrityCheckFailed); return SourceFailureDisposition.Continue; } if (result.FailureKind == MediaDownloadFailureKind.Cancelled) { await MarkFailedAsync(itemId, result.ToException(), VideoTaskErrorType.Interrupted); return SourceFailureDisposition.Continue; } var disposition = await RecordSourceFailureAsync(cookieId, result); if (disposition is SourceFailureDisposition.WaitForSource or SourceFailureDisposition.RequiresAuthorization) { var waitErrorType = result.FailureKind switch { MediaDownloadFailureKind.SourceUnauthorized => VideoTaskErrorType.CookieInvalid, MediaDownloadFailureKind.SourceRateLimited => VideoTaskErrorType.SourceRateLimited, _ => disposition == SourceFailureDisposition.RequiresAuthorization ? VideoTaskErrorType.CookieInvalid : VideoTaskErrorType.SourceForbidden }; await PauseCookieItemsForSourceAsync(cookieId, itemId, SourceWaitMessage(result, disposition), waitErrorType, includeRecentForbidden: result.FailureKind == MediaDownloadFailureKind.SourceForbidden); return disposition; } var errorType = result.FailureKind switch { MediaDownloadFailureKind.SourceForbidden => VideoTaskErrorType.SourceForbidden, MediaDownloadFailureKind.SourceRateLimited => VideoTaskErrorType.SourceRateLimited, MediaDownloadFailureKind.SourceUnauthorized => VideoTaskErrorType.CookieInvalid, _ => VideoTaskErrorType.SourceUnavailable }; await MarkFailedAsync(itemId, result.ToException(), errorType); return disposition; } public async Task GetSourceAccessAsync(string cookieId) { if (string.IsNullOrWhiteSpace(cookieId)) return new SourceAccessDecision { Allowed = false, Message = "任务缺少所属抖音授权。" }; var cookie = await _db.Queryable().InSingleAsync(cookieId); if (cookie == null) return new SourceAccessDecision { Allowed = false, Message = "任务所属抖音授权不存在。" }; if (cookie.SourceRequiresAuthorization) return new SourceAccessDecision { Allowed = false, Message = "该抖音授权需要重新授权。" }; var now = DateTime.UtcNow; if (cookie.SourceCooldownUntil.HasValue && cookie.SourceCooldownUntil.Value.ToUniversalTime() > now) return new SourceAccessDecision { Allowed = false, RetryAt = cookie.SourceCooldownUntil.Value, Message = $"抖音媒体来源正在冷却,将于 {cookie.SourceCooldownUntil.Value.ToLocalTime():yyyy-MM-dd HH:mm:ss} 自动探测。" }; return new SourceAccessDecision { Allowed = true, IsProbe = cookie.SourceProbePending }; } public async Task TryAcquireSourceAsync(string cookieId, string itemId = null) { var access = await GetSourceAccessAsync(cookieId); if (!access.Allowed || !access.IsProbe) return access; var cookie = await _db.Queryable().InSingleAsync(cookieId); if (cookie.SourceProbeInProgress) return new SourceAccessDecision { Allowed = false, RetryAt = cookie.SourceCooldownUntil, Message = "该授权正在执行来源恢复探测。" }; var oldest = await _db.Queryable() .Where(x => x.CookieId == cookieId && x.Stage == VideoTaskItemStage.WaitingForSource) .OrderBy(x => x.CreatedAt).FirstAsync(); if (oldest != null && !string.IsNullOrWhiteSpace(itemId) && oldest.Id != itemId) return new SourceAccessDecision { Allowed = false, Message = "等待由最早的失败条目执行来源恢复探测。" }; cookie.SourceProbeInProgress = true; cookie.SourceHealthUpdatedAt = DateTime.UtcNow; await _db.Updateable(cookie).ExecuteCommandAsync(); return new SourceAccessDecision { Allowed = true, IsProbe = true, Message = "正在探测抖音媒体来源是否恢复。" }; } public async Task RecordSourceSuccessAsync(string cookieId) { if (string.IsNullOrWhiteSpace(cookieId)) return; var cookie = await _db.Queryable().InSingleAsync(cookieId); if (cookie == null) return; var hadBlock = cookie.SourceProbePending || cookie.SourceProbeInProgress || cookie.SourceCooldownUntil.HasValue || cookie.ConsecutiveSourceForbidden > 0 || cookie.SourceRequiresAuthorization; cookie.ConsecutiveSourceForbidden = 0; cookie.SourceCooldownUntil = null; cookie.SourceRequiresAuthorization = false; cookie.SourceProbePending = false; cookie.SourceProbeInProgress = false; cookie.LastSourceStatusCode = null; cookie.LastSourceError = null; cookie.SourceHealthUpdatedAt = DateTime.UtcNow; await _db.Updateable(cookie).ExecuteCommandAsync(); if (!hadBlock) return; var waiting = await _db.Queryable() .Where(x => x.CookieId == cookieId && x.Stage == VideoTaskItemStage.WaitingForSource) .OrderBy(x => x.CreatedAt).ToListAsync(); foreach (var item in waiting) { item.Stage = VideoTaskItemStage.Pending; item.ErrorType = VideoTaskErrorType.None; item.ErrorMessage = null; item.HttpStatusCode = null; item.RetryAfter = null; item.CompletedAt = null; item.UpdatedAt = DateTime.Now; } if (waiting.Count == 0) return; await _db.Updateable(waiting).ExecuteCommandAsync(); var taskIds = waiting.Select(x => x.TaskId).Distinct().ToList(); await _db.Updateable() .SetColumns(x => new VideoDownloadTask { Status = VideoTaskStatus.Queued, ErrorMessage = null, CompletedAt = null, UpdatedAt = DateTime.Now }).Where(x => taskIds.Contains(x.Id) && x.Status == VideoTaskStatus.WaitingForSource).ExecuteCommandAsync(); foreach (var taskId in taskIds) await RefreshCountsAsync(taskId); } public async Task ResetSourceHealthAsync(string cookieId) { if (string.IsNullOrWhiteSpace(cookieId)) return; var cookie = await _db.Queryable().InSingleAsync(cookieId); if (cookie == null) return; cookie.ConsecutiveSourceForbidden = 0; cookie.SourceCooldownUntil = null; cookie.SourceRequiresAuthorization = false; cookie.SourceProbePending = true; cookie.SourceProbeInProgress = false; cookie.LastSourceStatusCode = null; cookie.LastSourceError = "Cookie 已更新,等待自动探测媒体来源。"; cookie.SourceHealthUpdatedAt = DateTime.UtcNow; await _db.Updateable(cookie).ExecuteCommandAsync(); var waiting = await _db.Queryable() .Where(x => x.CookieId == cookieId && x.Stage == VideoTaskItemStage.WaitingForSource).ToListAsync(); foreach (var item in waiting) { item.RetryAfter = null; item.ErrorMessage = "Cookie 已更新,等待最早条目自动探测媒体来源。"; item.UpdatedAt = DateTime.Now; } if (waiting.Count > 0) await _db.Updateable(waiting).ExecuteCommandAsync(); } public Task WaitItemForSourceAsync(string itemId, string cookieId, string message) => PauseCookieItemsForSourceAsync(cookieId, itemId, message, VideoTaskErrorType.SourceForbidden, includeRecentForbidden: false); public async Task CompleteTaskAsync(string taskId, string error = null) { await RefreshCountsAsync(taskId); var task = await RequireTaskAsync(taskId); if (task.Status is VideoTaskStatus.WaitingForStorage or VideoTaskStatus.WaitingForSource) return; var hasTaskError = !string.IsNullOrWhiteSpace(error); var hasPartialSuccess = task.SuccessCount > 0 || task.SkippedCount > 0; var status = hasTaskError ? (hasPartialSuccess ? VideoTaskStatus.PartiallyFailed : VideoTaskStatus.Failed) : task.FailedCount > 0 ? (task.SuccessCount > 0 || task.SkippedCount > 0 ? VideoTaskStatus.PartiallyFailed : VideoTaskStatus.Failed) : VideoTaskStatus.Completed; task.Status = status; task.ErrorMessage = error; task.CurrentFile = null; task.CompletedAt = DateTime.Now; task.UpdatedAt = DateTime.Now; await _db.Updateable(task).ExecuteCommandAsync(); } public async Task RefreshCountsAsync(string taskId) { var task = await _db.Queryable().InSingleAsync(taskId); if (task == null) return; var items = await _db.Queryable().Where(x => x.TaskId == taskId).ToListAsync(); task.TotalCount = items.Count; task.PendingCount = items.Count(x => x.Stage is VideoTaskItemStage.Pending or VideoTaskItemStage.WaitingForStorage or VideoTaskItemStage.WaitingForSource); task.RunningCount = items.Count(x => x.Stage is VideoTaskItemStage.Downloading or VideoTaskItemStage.Verifying or VideoTaskItemStage.Committing); task.SuccessCount = items.Count(x => x.Stage is VideoTaskItemStage.Succeeded or VideoTaskItemStage.SucceededWithWarnings); task.WarningCount = items.Count(x => x.Stage == VideoTaskItemStage.SucceededWithWarnings); task.FailedCount = items.Count(x => x.Stage == VideoTaskItemStage.Failed); task.SkippedCount = items.Count(x => x.Stage == VideoTaskItemStage.Skipped); task.UpdatedAt = DateTime.Now; await _db.Updateable(task).ExecuteCommandAsync(); } public async Task IsStorageAvailableAsync() { var type = _common.GetConfig()?.StorageType ?? MediaStorageType.Local; if (type == MediaStorageType.WebDav) return false; var fingerprint = await GetCurrentFingerprintAsync(type); return (await GetStorageHealthForTargetAsync(type, fingerprint)).Status != MediaStorageHealthStatus.Unavailable; } public async Task GetStorageHealthAsync() { var type = _common.GetConfig()?.StorageType ?? MediaStorageType.Local; return await GetStorageHealthForTargetAsync(type, await GetCurrentFingerprintAsync(type)); } public async Task EnsureTaskStorageAvailableAsync(string taskId) => await EnsureFingerprintAsync(await RequireTaskAsync(taskId)); public async Task<(bool Success, string Message)> ProbeStorageAsync(CancellationToken cancellationToken) { var storageType = _common.GetConfig()?.StorageType ?? MediaStorageType.Local; try { string message; if (storageType == MediaStorageType.OpenList) { var probe = await _openList.ProbeAsync(await _openListSettings.GetAsync(), cancellationToken); if (!probe.Success) throw new IOException(probe.Message); message = probe.Message; } else if (storageType == MediaStorageType.WebDav) { throw new InvalidOperationException("WebDAV 新写入已停用,请在系统配置中完成 OpenList 接管。"); } else { await ProbeLocalAsync(cancellationToken); message = "本地存储目录写入、读取与删除能力正常"; } var fingerprint = await GetCurrentFingerprintAsync(storageType); var health = await GetStorageHealthForTargetAsync(storageType, fingerprint); health.Status = MediaStorageHealthStatus.Healthy; health.StorageType = storageType; health.ConfigurationFingerprint = fingerprint; health.ConsecutiveFailures = 0; health.LastError = null; health.LastProbedAt = DateTime.Now; health.UpdatedAt = DateTime.Now; await _db.Storageable(health).ExecuteCommandAsync(); try { await ResumeStorageWaitsAsync(storageType, fingerprint); } catch (Exception ex) { Serilog.Log.Warning(ex, "存储已恢复,但重新排队等待任务失败"); } return (true, message); } catch (Exception ex) { var fingerprint = await GetCurrentFingerprintAsync(storageType); var health = await GetStorageHealthForTargetAsync(storageType, fingerprint); health.Status = MediaStorageHealthStatus.Unavailable; health.StorageType = storageType; health.ConfigurationFingerprint = fingerprint; health.LastError = ex.GetBaseException().Message; health.LastFailedAt = DateTime.Now; health.LastProbedAt = DateTime.Now; health.UpdatedAt = DateTime.Now; await _db.Storageable(health).ExecuteCommandAsync(); return (false, health.LastError); } } private async Task ResumeStorageWaitsAsync(MediaStorageType storageType, string fingerprint) { var tasks = await _db.Queryable() .Where(x => x.StorageType == storageType && x.StorageFingerprint == fingerprint && x.Status == VideoTaskStatus.WaitingForStorage) .ToListAsync(); if (tasks.Count == 0) return; var now = DateTime.Now; foreach (var task in tasks) { var items = await _db.Queryable() .Where(x => x.TaskId == task.Id).ToListAsync(); var waiting = items.Where(x => x.Stage == VideoTaskItemStage.WaitingForStorage).ToList(); if (items.Count == 0) { task.Status = VideoTaskStatus.Cancelled; task.ErrorMessage = "存储已恢复,系统已结束此无条目的等待任务;下一次定时同步会重新扫描。"; task.CurrentFile = null; task.CompletedAt = now; task.UpdatedAt = now; await _db.Updateable(task).ExecuteCommandAsync(); continue; } if (waiting.Count == 0) { var hasFailures = items.Any(x => x.Stage == VideoTaskItemStage.Failed); var hasSuccess = items.Any(x => x.Stage is VideoTaskItemStage.Succeeded or VideoTaskItemStage.SucceededWithWarnings or VideoTaskItemStage.Skipped); task.Status = hasFailures ? (hasSuccess ? VideoTaskStatus.PartiallyFailed : VideoTaskStatus.Failed) : VideoTaskStatus.Completed; task.CompletedAt = now; task.UpdatedAt = now; await _db.Updateable(task).ExecuteCommandAsync(); continue; } foreach (var item in waiting) { item.Stage = VideoTaskItemStage.Pending; item.ErrorType = VideoTaskErrorType.None; item.ErrorMessage = null; item.CompletedAt = null; item.RetryAfter = null; item.WorkerManaged = task.Type != VideoTaskType.Redownload; item.UpdatedAt = now; } await _db.Updateable(waiting).ExecuteCommandAsync(); var waitingIds = waiting.Select(x => x.Id).ToList(); await _db.Updateable() .SetColumns(x => new DouyinReDownload { Status = 0, ErrorMessage = null, UpdateTime = DateTime.UtcNow }) .Where(x => waitingIds.Contains(x.TaskItemId)) .ExecuteCommandAsync(); task.Status = VideoTaskStatus.Queued; task.ErrorMessage = null; task.CurrentFile = null; task.CompletedAt = null; task.UpdatedAt = now; await _db.Updateable(task).ExecuteCommandAsync(); await RefreshCountsAsync(task.Id); } } public async Task GetTasksAsync(VideoTaskPageRequest request) { request ??= new VideoTaskPageRequest(); var page = Math.Max(1, request.PageIndex); var size = Math.Clamp(request.PageSize, 1, 100); var all = (await GetAllTaskDtosAsync()).AsEnumerable(); if (request.Type.HasValue) all = all.Where(x => x.Type == request.Type.Value); if (request.Status.HasValue) all = all.Where(x => x.Status == request.Status.Value); if (request.Trigger.HasValue) all = all.Where(x => x.Trigger == request.Trigger.Value); if (!string.IsNullOrWhiteSpace(request.Keyword)) all = all.Where(x => (x.Title ?? string.Empty).Contains(request.Keyword, StringComparison.OrdinalIgnoreCase) || (x.ErrorMessage ?? string.Empty).Contains(request.Keyword, StringComparison.OrdinalIgnoreCase)); if (request.From.HasValue) all = all.Where(x => x.CreatedAt >= request.From.Value); if (request.To.HasValue) all = all.Where(x => x.CreatedAt <= request.To.Value); var ordered = all.OrderByDescending(x => x.CreatedAt).ToList(); return new VideoTaskListPage { TotalCount = ordered.Count, Items = ordered.Skip((page - 1) * size).Take(size).ToList() }; } public async Task GetTaskAsync(VideoTaskType type, string id) { if (type == VideoTaskType.StorageMigration) return ToDto((await _migration.GetAsync(id)).Task); if (type == VideoTaskType.StorageMaintenance) return ToDto((await _directoryRepair.GetAsync(id)).Task); return ToDto(await RequireTaskAsync(id)); } public async Task GetItemsAsync(VideoTaskType type, string taskId, VideoTaskItemPageRequest request) { request ??= new VideoTaskItemPageRequest(); var page = Math.Max(1, request.PageIndex); var size = Math.Clamp(request.PageSize, 1, 100); if (type == VideoTaskType.StorageMigration) { var migrationPage = await _migration.GetItemsAsync(taskId, new StorageMigrationItemPageRequest { PageIndex = page, PageSize = size, Stage = MapMigrationStageFilter(request.Stage) }); var mapped = migrationPage.Items.Select(ToDto).ToList(); if (!string.IsNullOrWhiteSpace(request.Keyword)) mapped = mapped.Where(x => ContainsKeyword(x, request.Keyword)).ToList(); return new VideoTaskItemPage { TotalCount = migrationPage.TotalCount, Items = mapped }; } if (type == VideoTaskType.StorageMaintenance) { var repairPage = await _directoryRepair.GetItemsAsync(taskId, new OpenListDirectoryRepairItemPageRequest { PageIndex = page, PageSize = size, Status = MapRepairStageFilter(request.Stage), Keyword = request.Keyword }); return new VideoTaskItemPage { TotalCount = repairPage.TotalCount, Items = repairPage.Items.Select(ToDto).ToList() }; } var query = _db.Queryable().Where(x => x.TaskId == taskId) .WhereIF(request.Stage.HasValue, x => x.Stage == request.Stage.Value) .WhereIF(request.ErrorType.HasValue, x => x.ErrorType == request.ErrorType.Value) .WhereIF(!request.IncludeSkipped && !request.Stage.HasValue, x => x.Stage != VideoTaskItemStage.Skipped) .WhereIF(!string.IsNullOrWhiteSpace(request.Keyword), x => x.VideoTitle.Contains(request.Keyword) || x.Author.Contains(request.Keyword) || x.AwemeId.Contains(request.Keyword)); return new VideoTaskItemPage { TotalCount = await query.CountAsync(), Items = (await query.OrderBy(x => x.CreatedAt).Skip((page - 1) * size).Take(size).ToListAsync()).Select(ToDto).ToList() }; } public async Task GetSummaryAsync() { var tasks = await GetAllTaskDtosAsync(); return new VideoTaskSummaryDto { Queued = tasks.Count(x => x.Status == VideoTaskStatus.Queued), Running = tasks.Count(x => x.Status == VideoTaskStatus.Running), WaitingForStorage = tasks.Count(x => x.Status == VideoTaskStatus.WaitingForStorage), WaitingForSource = tasks.Count(x => x.Status == VideoTaskStatus.WaitingForSource), Failed = tasks.Count(x => x.Status is VideoTaskStatus.Failed or VideoTaskStatus.PartiallyFailed or VideoTaskStatus.Interrupted), Completed = tasks.Count(x => x.Status is VideoTaskStatus.Completed or VideoTaskStatus.Cleaned or VideoTaskStatus.RolledBack), StorageHealth = await GetStorageHealthAsync(), SourceHealth = (await _db.Queryable().ToListAsync()).Select(ToSourceHealthDto).ToList() }; } private async Task> GetAllTaskDtosAsync() { var downloads = await _db.Queryable().Where(x => !x.IsArchived).ToListAsync(); var migrations = await _db.Queryable().Where(x => !x.IsArchived).ToListAsync(); var repairs = await _db.Queryable().ToListAsync(); return downloads.Select(ToDto).Concat(migrations.Select(ToDto)).Concat(repairs.Select(ToDto)).ToList(); } public async Task RetryFailedAsync(VideoTaskType type, string id) { if (type == VideoTaskType.StorageMigration) { await _migration.RetryFailedAsync(id); return; } if (type == VideoTaskType.StorageMaintenance) { await _directoryRepair.RetryFailedAsync(id); return; } var task = await RequireTaskAsync(id); await EnsureFingerprintAsync(task); var retryable = await _db.Queryable() .Where(x => x.TaskId == id && (x.Stage == VideoTaskItemStage.Failed || x.Stage == VideoTaskItemStage.WaitingForStorage)).ToListAsync(); if (task.Type != VideoTaskType.Redownload) retryable = retryable.Where(HasRetrySnapshot).ToList(); if (retryable.Count == 0) throw new InvalidOperationException("当前失败项缺少可恢复快照,请重新执行对应的同步任务。"); foreach (var item in retryable) { item.Stage = VideoTaskItemStage.Pending; item.ErrorMessage = null; item.ErrorType = VideoTaskErrorType.None; item.CompletedAt = null; item.WorkerManaged = task.Type != VideoTaskType.Redownload; item.UpdatedAt = DateTime.Now; } await _db.Updateable(retryable).ExecuteCommandAsync(); await _db.Updateable() .SetColumns(x => new DouyinReDownload { Status = 0, ErrorMessage = null, UpdateTime = DateTime.UtcNow }) .Where(x => x.TaskId == id && x.Status == 2).ExecuteCommandAsync(); task.Status = VideoTaskStatus.Queued; task.CompletedAt = null; task.ErrorMessage = null; task.UpdatedAt = DateTime.Now; await _db.Updateable(task).ExecuteCommandAsync(); await RefreshCountsAsync(id); } public async Task RetryItemAsync(VideoTaskType type, string taskId, string itemId) { if (type == VideoTaskType.StorageMigration) { await _migration.RetryFailedAsync(taskId); return; } if (type == VideoTaskType.StorageMaintenance) throw new InvalidOperationException("目录修复按任务重新扫描失败项,不支持单个目录直接重试。"); var task = await RequireTaskAsync(taskId); await EnsureFingerprintAsync(task); var item = await RequireItemAsync(itemId); if (item.TaskId != taskId || item.Stage is not (VideoTaskItemStage.Failed or VideoTaskItemStage.WaitingForStorage)) throw new InvalidOperationException("该条目当前不可重试。"); if (task.Type != VideoTaskType.Redownload && !HasRetrySnapshot(item)) throw new InvalidOperationException("该条目缺少可恢复快照,请重新执行对应的同步任务。"); item.Stage = VideoTaskItemStage.Pending; item.ErrorMessage = null; item.ErrorType = VideoTaskErrorType.None; item.CompletedAt = null; item.WorkerManaged = task.Type != VideoTaskType.Redownload; item.UpdatedAt = DateTime.Now; await _db.Updateable(item).ExecuteCommandAsync(); await _db.Updateable() .SetColumns(x => new DouyinReDownload { Status = 0, ErrorMessage = null, UpdateTime = DateTime.UtcNow }) .Where(x => x.TaskItemId == itemId).ExecuteCommandAsync(); task.Status = VideoTaskStatus.Queued; task.CompletedAt = null; task.UpdatedAt = DateTime.Now; await _db.Updateable(task).ExecuteCommandAsync(); await RefreshCountsAsync(taskId); } public async Task ExecuteMigrationActionAsync(string id, string action, CancellationToken cancellationToken) { switch ((action ?? string.Empty).ToLowerInvariant()) { case "pause": await _migration.PauseAsync(id); break; case "resume": await _migration.ResumeAsync(id); break; case "cancel": await _migration.CancelAsync(id); break; case "retry-failed": await _migration.RetryFailedAsync(id); break; case "cleanup": await _migration.CleanupAsync(id, cancellationToken); break; case "rollback": await _migration.RollbackAsync(id, cancellationToken); break; case "archive": await _migration.ArchiveAsync(id); break; default: throw new InvalidOperationException("不支持的任务操作。"); } } public async Task ExecuteTaskActionAsync( VideoTaskType type, string id, string action, CancellationToken cancellationToken) { if (type == VideoTaskType.StorageMigration) { await ExecuteMigrationActionAsync(id, action, cancellationToken); return; } if (type != VideoTaskType.StorageMaintenance) throw new InvalidOperationException("普通下载任务仅支持失败重试。"); switch ((action ?? string.Empty).ToLowerInvariant()) { case "cancel": await _directoryRepair.CancelAsync(id); break; case "resume": await _directoryRepair.ResumeAsync(id); break; case "retry-failed": await _directoryRepair.RetryFailedAsync(id); break; case "confirm-cleanup": throw new InvalidOperationException("清理前必须刷新目录状态并提交确认令牌。"); default: throw new InvalidOperationException("不支持的任务操作。"); } } public async Task RecoverInterruptedAsync() { var now = DateTime.Now; await ReconcileSupersededWebDavTasksAsync(now); await ReconcileStaleQueuedTasksAsync(now); var interruptedProbes = await _db.Queryable() .Where(x => x.SourceProbeInProgress).ToListAsync(); foreach (var cookie in interruptedProbes) { cookie.SourceProbeInProgress = false; cookie.SourceProbePending = true; cookie.SourceHealthUpdatedAt = DateTime.UtcNow; } if (interruptedProbes.Count > 0) await _db.Updateable(interruptedProbes).ExecuteCommandAsync(); var active = await _db.Queryable() .Where(x => x.Status == VideoTaskStatus.Running).ToListAsync(); foreach (var task in active) { var items = await _db.Queryable().Where(x => x.TaskId == task.Id && (x.Stage == VideoTaskItemStage.Pending || x.Stage == VideoTaskItemStage.Downloading || x.Stage == VideoTaskItemStage.Verifying || x.Stage == VideoTaskItemStage.Committing)).ToListAsync(); var resumedCount = 0; var failedCount = 0; foreach (var item in items) { var canResume = task.Type == VideoTaskType.Redownload ? await _db.Queryable() .Where(x => x.TaskItemId == item.Id && x.Status == 0).AnyAsync() : HasRetrySnapshot(item); if (canResume) { item.Stage = VideoTaskItemStage.Pending; item.ErrorType = VideoTaskErrorType.None; item.ErrorMessage = "应用重启,已自动重新排队。"; item.CompletedAt = null; item.WorkerManaged = task.Type != VideoTaskType.Redownload; resumedCount++; } else { item.Stage = VideoTaskItemStage.Failed; item.ErrorType = VideoTaskErrorType.Interrupted; item.ErrorMessage = "应用重启导致任务中断;该条目缺少安全恢复快照,请通过下一次同步重新处理。"; item.CompletedAt = now; failedCount++; } item.UpdatedAt = now; } if (items.Count > 0) await _db.Updateable(items).ExecuteCommandAsync(); task.Status = resumedCount > 0 ? VideoTaskStatus.Queued : VideoTaskStatus.Interrupted; task.ErrorMessage = resumedCount > 0 ? $"应用重启后已自动恢复 {resumedCount} 个条目" + (failedCount > 0 ? $";另有 {failedCount} 个无快照条目等待下一次同步。" : "。") : "应用重启导致任务中断;没有可自动恢复的条目,请等待或重新执行同步。"; task.CurrentFile = null; task.CompletedAt = resumedCount > 0 ? null : now; task.UpdatedAt = now; await _db.Updateable(task).ExecuteCommandAsync(); await RefreshCountsAsync(task.Id); } } private async Task ReconcileSupersededWebDavTasksAsync(DateTime now) { var currentStorage = _common.GetConfig()?.StorageType ?? MediaStorageType.Local; if (currentStorage != MediaStorageType.OpenList) return; var cutoff = now.AddMinutes(-5); var stale = await _db.Queryable() .Where(x => x.Type == VideoTaskType.Sync && x.StorageType == MediaStorageType.WebDav && x.UpdatedAt < cutoff && (x.Status == VideoTaskStatus.Queued || x.Status == VideoTaskStatus.Running || x.Status == VideoTaskStatus.WaitingForStorage || x.Status == VideoTaskStatus.WaitingForSource || x.Status == VideoTaskStatus.Interrupted)) .ToListAsync(); foreach (var task in stale) { var unresolved = await _db.Queryable() .Where(x => x.TaskId == task.Id && (x.Stage == VideoTaskItemStage.Pending || x.Stage == VideoTaskItemStage.Downloading || x.Stage == VideoTaskItemStage.Verifying || x.Stage == VideoTaskItemStage.Committing || x.Stage == VideoTaskItemStage.WaitingForStorage || x.Stage == VideoTaskItemStage.WaitingForSource)) .ToListAsync(); foreach (var item in unresolved) { item.Stage = VideoTaskItemStage.Cancelled; item.ErrorType = VideoTaskErrorType.ConfigurationChanged; item.ErrorMessage = "目标存储已切换到 OpenList,旧 WebDAV 条目已安全终结;作品将由后续同步写入当前存储。"; item.WorkerManaged = false; item.CompletedAt = now; item.UpdatedAt = now; } if (unresolved.Count > 0) await _db.Updateable(unresolved).ExecuteCommandAsync(); task.Status = VideoTaskStatus.Cancelled; task.IsArchived = true; task.ErrorMessage = "目标存储已切换到 OpenList,旧 WebDAV 任务已安全终结并从任务中心隐藏;未删除视频记录或媒体文件。"; task.CurrentFile = null; task.CompletedAt = now; task.UpdatedAt = now; await _db.Updateable(task).ExecuteCommandAsync(); await RefreshCountsAsync(task.Id); } } private async Task ReconcileStaleQueuedTasksAsync(DateTime now) { var cutoff = now.AddMinutes(-15); var stale = await _db.Queryable() .Where(x => x.Status == VideoTaskStatus.Queued && x.UpdatedAt < cutoff).ToListAsync(); foreach (var task in stale) { if (await _db.Queryable().Where(x => x.TaskId == task.Id).AnyAsync()) continue; task.Status = VideoTaskStatus.Cancelled; task.ErrorMessage = "任务排队后未进入调度,系统已自动结束该空任务;如仍需要,请重新发起同步。"; task.CurrentFile = null; task.CompletedAt = now; task.UpdatedAt = now; await _db.Updateable(task).ExecuteCommandAsync(); } } public async Task CleanupHistoryAsync() { var successBefore = DateTime.Now.AddDays(-30); var failedBefore = DateTime.Now.AddDays(-90); await _db.Deleteable().Where(x => (x.CompletedAt < successBefore && (x.Stage == VideoTaskItemStage.Succeeded || x.Stage == VideoTaskItemStage.SucceededWithWarnings || x.Stage == VideoTaskItemStage.Skipped)) || (x.CompletedAt < failedBefore && x.Stage == VideoTaskItemStage.Failed)).ExecuteCommandAsync(); await _db.Deleteable().Where(x => (x.Status == 1 && x.UpdateTime < successBefore) || (x.Status == 2 && x.UpdateTime < failedBefore)).ExecuteCommandAsync(); var finalMigrationIds = await _db.Queryable() .Where(x => (x.Status == StorageMigrationTaskStatus.Cleaned || x.Status == StorageMigrationTaskStatus.RolledBack) && x.UpdatedAt < failedBefore).Select(x => x.Id).ToListAsync(); if (finalMigrationIds.Count > 0) await _db.Deleteable().Where(x => finalMigrationIds.Contains(x.TaskId)).ExecuteCommandAsync(); } private async Task RecordStorageFailureAsync(string taskId, string message) { var task = await _db.Queryable().InSingleAsync(taskId); var type = task?.StorageType ?? (_common.GetConfig()?.StorageType ?? MediaStorageType.Local); var fingerprint = task?.StorageFingerprint; if (string.IsNullOrWhiteSpace(fingerprint)) fingerprint = await GetCurrentFingerprintAsync(type); var health = await GetStorageHealthForTargetAsync(type, fingerprint); health.StorageType = type; health.ConfigurationFingerprint = fingerprint; health.ConsecutiveFailures++; health.LastError = message; health.LastFailedAt = DateTime.Now; health.UpdatedAt = DateTime.Now; if (health.ConsecutiveFailures >= 3) { health.Status = MediaStorageHealthStatus.Unavailable; var affectedTaskIds = await _db.Queryable() .Where(x => x.StorageType == type && x.StorageFingerprint == fingerprint && (x.Status == VideoTaskStatus.Queued || x.Status == VideoTaskStatus.Running)) .Select(x => x.Id).ToListAsync(); if (!affectedTaskIds.Contains(taskId)) affectedTaskIds.Add(taskId); if (affectedTaskIds.Count > 0) { await _db.Updateable() .SetColumns(x => new VideoDownloadTaskItem { Stage = VideoTaskItemStage.WaitingForStorage, ErrorType = VideoTaskErrorType.StorageUnavailable, ErrorMessage = "连续存储失败,等待存储恢复。", UpdatedAt = DateTime.Now }).Where(x => affectedTaskIds.Contains(x.TaskId) && x.Stage == VideoTaskItemStage.Pending) .ExecuteCommandAsync(); await _db.Updateable() .SetColumns(x => new VideoDownloadTask { Status = VideoTaskStatus.WaitingForStorage, ErrorMessage = message, UpdatedAt = DateTime.Now }).Where(x => affectedTaskIds.Contains(x.Id)).ExecuteCommandAsync(); } } await _db.Storageable(health).ExecuteCommandAsync(); } private async Task RecordSourceFailureAsync(string cookieId, MediaDownloadResult result) { if (string.IsNullOrWhiteSpace(cookieId)) return SourceFailureDisposition.Continue; var cookie = await _db.Queryable().InSingleAsync(cookieId); if (cookie == null) return SourceFailureDisposition.Continue; var now = DateTime.UtcNow; cookie.LastSourceStatusCode = result.HttpStatusCode; cookie.LastSourceError = result.Message; cookie.SourceHealthUpdatedAt = now; SourceFailureDisposition disposition; switch (result.FailureKind) { case MediaDownloadFailureKind.SourceUnauthorized: cookie.SourceRequiresAuthorization = true; cookie.SourceCooldownUntil = null; cookie.SourceProbePending = false; cookie.SourceProbeInProgress = false; disposition = SourceFailureDisposition.RequiresAuthorization; break; case MediaDownloadFailureKind.SourceForbidden when cookie.SourceProbeInProgress: cookie.SourceRequiresAuthorization = true; cookie.SourceCooldownUntil = null; cookie.SourceProbePending = false; cookie.SourceProbeInProgress = false; disposition = SourceFailureDisposition.RequiresAuthorization; break; case MediaDownloadFailureKind.SourceForbidden: cookie.ConsecutiveSourceForbidden++; if (cookie.ConsecutiveSourceForbidden >= 3) { cookie.SourceCooldownUntil = now.AddMinutes(15); cookie.SourceProbePending = true; cookie.SourceProbeInProgress = false; disposition = SourceFailureDisposition.WaitForSource; } else { disposition = SourceFailureDisposition.Continue; } break; case MediaDownloadFailureKind.SourceRateLimited: cookie.SourceCooldownUntil = result.RetryAfter.HasValue && result.RetryAfter.Value.ToUniversalTime() > now ? result.RetryAfter.Value.ToUniversalTime() : now.AddMinutes(15); cookie.SourceProbePending = true; cookie.SourceProbeInProgress = false; disposition = SourceFailureDisposition.WaitForSource; break; default: cookie.SourceProbeInProgress = false; disposition = SourceFailureDisposition.Continue; break; } await _db.Updateable(cookie).ExecuteCommandAsync(); return disposition; } private async Task PauseCookieItemsForSourceAsync( string cookieId, string currentItemId, string message, VideoTaskErrorType errorType, bool includeRecentForbidden) { var active = await _db.Queryable() .Where(x => x.CookieId == cookieId && (x.Id == currentItemId || x.Stage == VideoTaskItemStage.Pending || x.Stage == VideoTaskItemStage.Downloading || x.Stage == VideoTaskItemStage.Verifying)) .ToListAsync(); if (includeRecentForbidden) { var recentForbidden = await _db.Queryable() .Where(x => x.CookieId == cookieId && x.Stage == VideoTaskItemStage.Failed && x.ErrorType == VideoTaskErrorType.SourceForbidden) .OrderBy(x => x.UpdatedAt, OrderByType.Desc).Take(2).ToListAsync(); foreach (var failed in recentForbidden) if (active.All(x => x.Id != failed.Id)) active.Add(failed); } if (active.All(x => x.Id != currentItemId)) { var current = await _db.Queryable().InSingleAsync(currentItemId); if (current != null) active.Add(current); } var taskIds = active.Select(x => x.TaskId).Distinct().ToList(); var tasks = taskIds.Count == 0 ? new List() : await _db.Queryable().Where(x => taskIds.Contains(x.Id)).ToListAsync(); foreach (var item in active) { item.Stage = VideoTaskItemStage.WaitingForSource; item.ErrorType = errorType; item.ErrorMessage = message; item.CompletedAt = null; item.WorkerManaged = tasks.FirstOrDefault(x => x.Id == item.TaskId)?.Type != VideoTaskType.Redownload; item.UpdatedAt = DateTime.Now; } if (active.Count > 0) await _db.Updateable(active).ExecuteCommandAsync(); var activeIds = active.Select(x => x.Id).ToList(); if (activeIds.Count > 0) await _db.Updateable() .SetColumns(x => new DouyinReDownload { Status = 0, ErrorMessage = null, UpdateTime = DateTime.UtcNow }) .Where(x => activeIds.Contains(x.TaskItemId)).ExecuteCommandAsync(); foreach (var task in tasks) { task.Status = VideoTaskStatus.WaitingForSource; task.ErrorMessage = message; task.CompletedAt = null; task.UpdatedAt = DateTime.Now; } if (tasks.Count > 0) await _db.Updateable(tasks).ExecuteCommandAsync(); foreach (var taskId in taskIds) await RefreshCountsAsync(taskId); } private static string SourceWaitMessage(MediaDownloadResult result, SourceFailureDisposition disposition) { if (disposition == SourceFailureDisposition.RequiresAuthorization) return "抖音媒体来源仍拒绝访问,请重新授权该账号后再试。"; if (result.FailureKind == MediaDownloadFailureKind.SourceRateLimited) return result.RetryAfter.HasValue ? $"抖音请求过于频繁,等待至 {result.RetryAfter.Value.ToLocalTime():yyyy-MM-dd HH:mm:ss} 自动探测。" : "抖音请求过于频繁,等待自动探测。"; return "连续 3 个作品的全部候选地址均返回 403,已冷却 15 分钟,之后只探测最早的等待条目。"; } private async Task RecordStorageSuccessAsync(string taskId) { var task = await _db.Queryable().InSingleAsync(taskId); var type = task?.StorageType ?? (_common.GetConfig()?.StorageType ?? MediaStorageType.Local); var fingerprint = task?.StorageFingerprint; if (string.IsNullOrWhiteSpace(fingerprint)) fingerprint = await GetCurrentFingerprintAsync(type); var health = await GetStorageHealthForTargetAsync(type, fingerprint); if (health.Status == MediaStorageHealthStatus.Unavailable) return; health.ConsecutiveFailures = 0; health.LastError = null; health.UpdatedAt = DateTime.Now; await _db.Storageable(health).ExecuteCommandAsync(); } private async Task GetStorageHealthForTargetAsync(MediaStorageType type, string fingerprint) { fingerprint ??= string.Empty; var health = await _db.Queryable() .Where(x => x.StorageType == type && x.ConfigurationFingerprint == fingerprint) .OrderByDescending(x => x.UpdatedAt) .FirstAsync(); if (health != null) return health; var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{(int)type}|{fingerprint}"))) .ToLowerInvariant(); return new MediaStorageHealth { Id = $"target-{hash[..40]}", Status = MediaStorageHealthStatus.Healthy, StorageType = type, ConfigurationFingerprint = fingerprint, UpdatedAt = DateTime.Now }; } private async Task ProbeLocalAsync(CancellationToken cancellationToken) { var cookies = await _db.Queryable().ToListAsync(); var paths = cookies.SelectMany(x => new[] { x.SavePath, x.FavSavePath, x.UpSavePath, x.MixPath, x.SeriesPath }) .Where(x => !string.IsNullOrWhiteSpace(x)).Select(Path.GetFullPath).Distinct(StringComparer.Ordinal).ToList(); if (paths.Count == 0 && !string.IsNullOrWhiteSpace(ServiceExtension.FnDataFolder)) paths.Add(Path.GetFullPath(ServiceExtension.FnDataFolder)); if (paths.Count == 0) throw new InvalidOperationException("没有可检测的本地存储路径。"); foreach (var path in paths) { Directory.CreateDirectory(path); var probe = Path.Combine(path, $".dysync-storage-probe-{Guid.NewGuid():N}"); try { var bytes = Encoding.UTF8.GetBytes("dysync-storage-probe"); await File.WriteAllBytesAsync(probe, bytes, cancellationToken); var read = await File.ReadAllBytesAsync(probe, cancellationToken); if (!bytes.SequenceEqual(read)) throw new IOException($"本地存储校验失败:{path}"); } finally { if (File.Exists(probe)) File.Delete(probe); } } } private async Task GetCurrentFingerprintAsync(MediaStorageType storageType) { if (storageType == MediaStorageType.OpenList) return StorageConfigurationFingerprint.Create(await _openListSettings.GetAsync()); if (storageType == MediaStorageType.WebDav) return StorageConfigurationFingerprint.Create(await _webDavSettings.GetAsync()); var cookies = await _db.Queryable().ToListAsync(); var value = string.Join("\n", cookies.OrderBy(x => x.Id).Select(x => string.Join('|', x.Id, x.SavePath, x.FavSavePath, x.UpSavePath, x.MixPath, x.SeriesPath))); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); } private async Task EnsureFingerprintAsync(VideoDownloadTask task) { var current = await GetCurrentFingerprintAsync(task.StorageType); if (!string.Equals(current, task.StorageFingerprint, StringComparison.Ordinal)) throw new InvalidOperationException("存储配置已变化,请重新发起同步或重新下载,原任务不会写入新目标。"); var health = await GetStorageHealthForTargetAsync(task.StorageType, task.StorageFingerprint); if (health.Status == MediaStorageHealthStatus.Unavailable) throw new InvalidOperationException("存储仍不可用,请先重新检测存储。"); } private async Task RequireTaskAsync(string id) => await _db.Queryable().InSingleAsync(id) ?? throw new KeyNotFoundException("任务不存在。"); private async Task RequireItemAsync(string id) => await _db.Queryable().InSingleAsync(id) ?? throw new KeyNotFoundException("任务条目不存在。"); private static VideoTaskErrorType Classify(Exception error) { if (error is MediaStorageException) return VideoTaskErrorType.StorageUnavailable; if (error is MediaIntegrityException) return VideoTaskErrorType.IntegrityCheckFailed; if (error is MediaSourceException source) return source.FailureKind switch { MediaDownloadFailureKind.SourceUnauthorized => VideoTaskErrorType.CookieInvalid, MediaDownloadFailureKind.SourceForbidden => VideoTaskErrorType.SourceForbidden, MediaDownloadFailureKind.SourceRateLimited => VideoTaskErrorType.SourceRateLimited, _ => VideoTaskErrorType.SourceUnavailable }; var message = error?.GetBaseException().Message ?? string.Empty; if (message.Contains("Cookie", StringComparison.OrdinalIgnoreCase) || message.Contains("登录", StringComparison.Ordinal)) return VideoTaskErrorType.CookieInvalid; if (message.Contains("长度", StringComparison.Ordinal) || message.Contains("为空", StringComparison.Ordinal)) return VideoTaskErrorType.IntegrityCheckFailed; if (error is IOException or UnauthorizedAccessException || message.Contains("WebDAV", StringComparison.OrdinalIgnoreCase) || message.Contains("存储", StringComparison.Ordinal)) return VideoTaskErrorType.StorageUnavailable; if (message.Contains("下架", StringComparison.Ordinal) || message.Contains("私密", StringComparison.Ordinal) || message.Contains("地址", StringComparison.Ordinal)) return VideoTaskErrorType.SourceUnavailable; return VideoTaskErrorType.Unknown; } internal static string DescribeError(Exception error) { if (error == null) return "未知错误"; if (error is MediaStorageException storageError) return DouyinHttpClientService.DescribeStorageFailure(storageError); return error.GetBaseException().Message; } private static UnifiedVideoTaskDto ToDto(VideoDownloadTask task) => new() { Id = task.Id, Type = task.Type, Trigger = task.Trigger, Status = task.Status, Title = task.Title, VideoType = task.VideoType, StorageType = task.StorageType, TotalCount = task.TotalCount, PendingCount = task.PendingCount, RunningCount = task.RunningCount, SuccessCount = task.SuccessCount, WarningCount = task.WarningCount, FailedCount = task.FailedCount, SkippedCount = task.SkippedCount, CurrentFile = task.CurrentFile, ErrorMessage = task.ErrorMessage, CreatedAt = task.CreatedAt, UpdatedAt = task.UpdatedAt, CompletedAt = task.CompletedAt, AvailableActions = task.FailedCount > 0 ? new List { "retry-failed" } : new List() }; private static UnifiedVideoTaskDto ToDto(StorageMigrationTask task) { var target = task.TargetStorageType ?? MediaStorageType.WebDav; return new UnifiedVideoTaskDto { Id = task.Id, Type = VideoTaskType.StorageMigration, Trigger = VideoTaskTrigger.UserAction, Status = Map(task.Status), Title = target == MediaStorageType.OpenList ? "旧存储接管到 OpenList" : "历史 WebDAV 存储迁移", StorageType = target, TotalCount = task.TotalCount, PendingCount = task.PendingCount, RunningCount = task.Status == StorageMigrationTaskStatus.Running ? 1 : 0, SuccessCount = task.SuccessCount, WarningCount = task.WarningCount, FailedCount = task.FailedCount, CurrentFile = task.CurrentFile, ErrorMessage = task.ErrorMessage, CreatedAt = task.CreatedAt, UpdatedAt = task.UpdatedAt, CompletedAt = task.CompletedAt, RemovedCount = task.RemovedCount, AvailableActions = MigrationActions(task) }; } private static UnifiedVideoTaskDto ToDto(OpenListDirectoryRepairTask task) => new() { Id = task.Id, Type = VideoTaskType.StorageMaintenance, Trigger = VideoTaskTrigger.UserAction, Status = Map(task.Status), Title = "OpenList 异常目录修复", StorageType = MediaStorageType.OpenList, TotalCount = task.TotalCount, PendingCount = task.PendingCount, RunningCount = task.Status is OpenListDirectoryRepairStatus.Scanning or OpenListDirectoryRepairStatus.Cleaning ? 1 : 0, SuccessCount = task.EmptyCount + task.DeletedCount, FailedCount = task.FailedCount, SkippedCount = task.SkippedCount + task.MissingCount, RemovedCount = task.DeletedCount, CurrentFile = task.CurrentDirectory, ErrorMessage = task.ErrorMessage, CreatedAt = task.CreatedAt, UpdatedAt = task.UpdatedAt, CompletedAt = task.CompletedAt, AvailableActions = RepairActions(task) }; private static UnifiedVideoTaskItemDto ToDto(VideoDownloadTaskItem item) => new() { Id = item.Id, TaskId = item.TaskId, VideoId = item.VideoId, AwemeId = item.AwemeId, CookieName = item.CookieName, VideoType = item.VideoType, VideoTitle = item.VideoTitle, Author = item.Author, TargetPath = item.TargetPath, Stage = item.Stage, ErrorType = item.ErrorType, SkipReason = item.SkipReason, Attempts = item.Attempts, ExpectedLength = item.ExpectedLength, ActualLength = item.ActualLength, ErrorMessage = item.ErrorMessage, WarningMessage = item.WarningMessage, CleanupError = item.CleanupError, SourceHost = item.SourceHost, HttpStatusCode = item.HttpStatusCode, RetryAfter = item.RetryAfter, ExclusionId = item.ExclusionId, ExclusionReleasedAt = item.ExclusionReleasedAt, RelatedTaskId = item.RelatedTaskId, CreatedAt = item.CreatedAt, UpdatedAt = item.UpdatedAt, CompletedAt = item.CompletedAt, CanRetry = (item.Stage is VideoTaskItemStage.Failed or VideoTaskItemStage.WaitingForStorage) && HasRetrySnapshot(item), CanRetryCleanup = CanRetryCleanup(item), CanUnexclude = item.SkipReason == VideoTaskSkipReason.PermanentlyExcluded && !item.ExclusionReleasedAt.HasValue }; private static DouyinSourceHealthDto ToSourceHealthDto(DouyinCookie cookie) => new() { CookieId = cookie.Id, CookieName = cookie.UserName, ConsecutiveForbidden = cookie.ConsecutiveSourceForbidden, CooldownUntil = cookie.SourceCooldownUntil, RequiresAuthorization = cookie.SourceRequiresAuthorization, ProbePending = cookie.SourceProbePending, LastStatusCode = cookie.LastSourceStatusCode, LastError = cookie.LastSourceError, UpdatedAt = cookie.SourceHealthUpdatedAt }; internal static bool HasRetrySnapshot(VideoDownloadTaskItem item) => item != null && !string.IsNullOrWhiteSpace(item.RetrySnapshotJson); internal static bool CanRetryCleanup(VideoDownloadTaskItem item) { if (item?.Stage != VideoTaskItemStage.SucceededWithWarnings || !HasRetrySnapshot(item)) return false; if (item.CleanupPending) return true; return item.WarningMessage?.Contains("旧本地文件清理失败", StringComparison.Ordinal) == true; } private static UnifiedVideoTaskItemDto ToDto(StorageMigrationItem item) => new() { Id = item.Id, TaskId = item.TaskId, VideoId = item.VideoId, TargetPath = item.TargetVideoPath, Stage = item.Stage switch { StorageMigrationItemStage.Pending => VideoTaskItemStage.Pending, StorageMigrationItemStage.Uploading => VideoTaskItemStage.Downloading, StorageMigrationItemStage.Verifying => VideoTaskItemStage.Verifying, StorageMigrationItemStage.Committing => VideoTaskItemStage.Committing, StorageMigrationItemStage.Succeeded => VideoTaskItemStage.Succeeded, StorageMigrationItemStage.SucceededWithWarnings => VideoTaskItemStage.SucceededWithWarnings, StorageMigrationItemStage.Failed => VideoTaskItemStage.Failed, StorageMigrationItemStage.Cleaned => VideoTaskItemStage.Cleaned, StorageMigrationItemStage.RolledBack => VideoTaskItemStage.RolledBack, StorageMigrationItemStage.RecordRemoved => VideoTaskItemStage.RecordRemoved, _ => VideoTaskItemStage.Failed }, ErrorType = item.Stage == StorageMigrationItemStage.Failed ? VideoTaskErrorType.Unknown : VideoTaskErrorType.None, Attempts = item.Attempts, ExpectedLength = item.ExpectedLength, ErrorMessage = item.ErrorMessage, WarningMessage = item.WarningMessage, CreatedAt = item.CreatedAt, UpdatedAt = item.UpdatedAt, CompletedAt = item.CompletedAt, CanRetry = item.Stage == StorageMigrationItemStage.Failed }; private static UnifiedVideoTaskItemDto ToDto(OpenListDirectoryRepairItem item) => new() { Id = item.Id, TaskId = item.TaskId, VideoTitle = item.DirectoryName, TargetPath = item.ActualPath, Stage = item.Status switch { OpenListDirectoryRepairItemStatus.Pending => VideoTaskItemStage.Pending, OpenListDirectoryRepairItemStatus.Inspecting => VideoTaskItemStage.Inspecting, OpenListDirectoryRepairItemStatus.EmptyConfirmed => VideoTaskItemStage.EmptyConfirmed, OpenListDirectoryRepairItemStatus.SkippedNonEmpty => VideoTaskItemStage.SkippedNonEmpty, OpenListDirectoryRepairItemStatus.Deleting => VideoTaskItemStage.Verifying, OpenListDirectoryRepairItemStatus.Deleted => VideoTaskItemStage.Cleaned, OpenListDirectoryRepairItemStatus.Missing => VideoTaskItemStage.Skipped, _ => VideoTaskItemStage.Failed }, ErrorType = item.Status == OpenListDirectoryRepairItemStatus.Failed ? VideoTaskErrorType.StorageUnavailable : VideoTaskErrorType.None, Attempts = item.Attempts, ActualLength = item.EntryCount, ErrorMessage = item.ErrorMessage, CreatedAt = item.CreatedAt, UpdatedAt = item.UpdatedAt, CompletedAt = item.CompletedAt, CanRetry = false }; private static bool ContainsKeyword(UnifiedVideoTaskItemDto item, string keyword) => (item.VideoTitle ?? string.Empty).Contains(keyword, StringComparison.OrdinalIgnoreCase) || (item.Author ?? string.Empty).Contains(keyword, StringComparison.OrdinalIgnoreCase) || (item.AwemeId ?? string.Empty).Contains(keyword, StringComparison.OrdinalIgnoreCase) || (item.ErrorMessage ?? string.Empty).Contains(keyword, StringComparison.OrdinalIgnoreCase); private static StorageMigrationItemStage? MapMigrationStageFilter(VideoTaskItemStage? stage) => stage switch { VideoTaskItemStage.Pending => StorageMigrationItemStage.Pending, VideoTaskItemStage.Downloading => StorageMigrationItemStage.Uploading, VideoTaskItemStage.Verifying => StorageMigrationItemStage.Verifying, VideoTaskItemStage.Committing => StorageMigrationItemStage.Committing, VideoTaskItemStage.Succeeded => StorageMigrationItemStage.Succeeded, VideoTaskItemStage.SucceededWithWarnings => StorageMigrationItemStage.SucceededWithWarnings, VideoTaskItemStage.Failed => StorageMigrationItemStage.Failed, VideoTaskItemStage.Cleaned => StorageMigrationItemStage.Cleaned, VideoTaskItemStage.RolledBack => StorageMigrationItemStage.RolledBack, VideoTaskItemStage.RecordRemoved => StorageMigrationItemStage.RecordRemoved, _ => null }; private static OpenListDirectoryRepairItemStatus? MapRepairStageFilter(VideoTaskItemStage? stage) => stage switch { VideoTaskItemStage.Pending => OpenListDirectoryRepairItemStatus.Pending, VideoTaskItemStage.Inspecting => OpenListDirectoryRepairItemStatus.Inspecting, VideoTaskItemStage.EmptyConfirmed => OpenListDirectoryRepairItemStatus.EmptyConfirmed, VideoTaskItemStage.SkippedNonEmpty => OpenListDirectoryRepairItemStatus.SkippedNonEmpty, VideoTaskItemStage.Verifying => OpenListDirectoryRepairItemStatus.Deleting, VideoTaskItemStage.Cleaned => OpenListDirectoryRepairItemStatus.Deleted, VideoTaskItemStage.Failed => OpenListDirectoryRepairItemStatus.Failed, VideoTaskItemStage.Skipped => OpenListDirectoryRepairItemStatus.Missing, _ => null }; private static VideoTaskStatus Map(StorageMigrationTaskStatus status) => status switch { StorageMigrationTaskStatus.Queued => VideoTaskStatus.Queued, StorageMigrationTaskStatus.Running => VideoTaskStatus.Running, StorageMigrationTaskStatus.Paused => VideoTaskStatus.Paused, StorageMigrationTaskStatus.Completed => VideoTaskStatus.Completed, StorageMigrationTaskStatus.PartiallyFailed => VideoTaskStatus.PartiallyFailed, StorageMigrationTaskStatus.Cancelled => VideoTaskStatus.Cancelled, StorageMigrationTaskStatus.Cleaning => VideoTaskStatus.Cleaning, StorageMigrationTaskStatus.Cleaned => VideoTaskStatus.Cleaned, StorageMigrationTaskStatus.RolledBack => VideoTaskStatus.RolledBack, _ => VideoTaskStatus.Failed }; private static VideoTaskStatus Map(OpenListDirectoryRepairStatus status) => status switch { OpenListDirectoryRepairStatus.Queued => VideoTaskStatus.Queued, OpenListDirectoryRepairStatus.Scanning => VideoTaskStatus.Scanning, OpenListDirectoryRepairStatus.AwaitingConfirmation => VideoTaskStatus.AwaitingConfirmation, OpenListDirectoryRepairStatus.Cleaning => VideoTaskStatus.Cleaning, OpenListDirectoryRepairStatus.Completed => VideoTaskStatus.Completed, OpenListDirectoryRepairStatus.PartiallyFailed => VideoTaskStatus.PartiallyFailed, OpenListDirectoryRepairStatus.Cancelled => VideoTaskStatus.Cancelled, OpenListDirectoryRepairStatus.Paused => VideoTaskStatus.Paused, _ => VideoTaskStatus.Failed }; private static List MigrationActions(StorageMigrationTask task) { var actions = new List(); var status = task.Status; var isOpenListTask = task.TargetStorageType == MediaStorageType.OpenList; if (!isOpenListTask) { if (!task.IsArchived && status is (StorageMigrationTaskStatus.Completed or StorageMigrationTaskStatus.PartiallyFailed or StorageMigrationTaskStatus.Cancelled or StorageMigrationTaskStatus.Cleaned or StorageMigrationTaskStatus.RolledBack)) actions.Add("archive"); return actions; } if (status is StorageMigrationTaskStatus.Queued or StorageMigrationTaskStatus.Running) actions.AddRange(new[] { "pause", "cancel" }); if (status == StorageMigrationTaskStatus.Paused) actions.AddRange(new[] { "resume", "cancel" }); if (task.FailedCount > 0 && status is StorageMigrationTaskStatus.PartiallyFailed or StorageMigrationTaskStatus.Cancelled) actions.Add("retry-failed"); if (task.SuccessCount > 0 && status is (StorageMigrationTaskStatus.Completed or StorageMigrationTaskStatus.PartiallyFailed or StorageMigrationTaskStatus.Cancelled)) actions.AddRange(new[] { "cleanup", "rollback" }); if (!task.IsArchived && (status is StorageMigrationTaskStatus.Cleaned or StorageMigrationTaskStatus.RolledBack || task.SuccessCount == 0 && status is (StorageMigrationTaskStatus.Completed or StorageMigrationTaskStatus.PartiallyFailed or StorageMigrationTaskStatus.Cancelled))) actions.Add("archive"); return actions; } private static List RepairActions(OpenListDirectoryRepairTask task) { var actions = new List(); if (task.Status is OpenListDirectoryRepairStatus.Queued or OpenListDirectoryRepairStatus.Scanning or OpenListDirectoryRepairStatus.Cleaning) actions.Add("cancel"); if (task.Status == OpenListDirectoryRepairStatus.AwaitingConfirmation) { if (task.EmptyCount > 0) actions.Add("confirm-cleanup"); actions.Add("cancel"); } if (task.Status is OpenListDirectoryRepairStatus.Cancelled or OpenListDirectoryRepairStatus.Paused) actions.Add("resume"); if (task.FailedCount > 0 && task.Status is (OpenListDirectoryRepairStatus.PartiallyFailed or OpenListDirectoryRepairStatus.Cancelled or OpenListDirectoryRepairStatus.Paused)) actions.Add("retry-failed"); return actions; } private static string TaskTypeText(VideoTaskType type) => type switch { VideoTaskType.Sync => "视频同步", VideoTaskType.Redownload => "重新下载", VideoTaskType.ExclusionRestore => "取消排除恢复", VideoTaskType.StorageMigration => "存储迁移", VideoTaskType.StorageMaintenance => "存储维护", _ => "视频任务" }; } }