using dy.net.model.dto; using dy.net.model.entity; using dy.net.storage; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using SqlSugar; namespace dy.net.service { public class VideoTaskWorker : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; public VideoTaskWorker(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using (var scope = _scopeFactory.CreateScope()) { var tasks = scope.ServiceProvider.GetRequiredService(); await tasks.RecoverInterruptedAsync(); await BackfillLegacyRedownloadsAsync(scope.ServiceProvider); await tasks.CleanupHistoryAsync(); } var lastCleanup = DateTime.UtcNow; while (!stoppingToken.IsCancellationRequested) { try { var worked = await ProcessOneAsync(stoppingToken); if (DateTime.UtcNow - lastCleanup > TimeSpan.FromDays(1)) { using var scope = _scopeFactory.CreateScope(); await scope.ServiceProvider.GetRequiredService().CleanupHistoryAsync(); lastCleanup = DateTime.UtcNow; } if (!worked) await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } catch (Exception ex) { Serilog.Log.Error(ex, "视频任务后台服务异常"); await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken); } } } private async Task ProcessOneAsync(CancellationToken cancellationToken) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var tasks = scope.ServiceProvider.GetRequiredService(); var candidates = await db.Queryable() .Where(x => x.WorkerManaged && (x.Stage == VideoTaskItemStage.Pending || x.Stage == VideoTaskItemStage.WaitingForSource)) .OrderBy(x => x.CreatedAt).Take(50).ToListAsync(); VideoDownloadTaskItem item = null; var isSourceProbe = false; foreach (var candidate in candidates) { var access = candidate.Stage == VideoTaskItemStage.WaitingForSource ? await tasks.TryAcquireSourceAsync(candidate.CookieId, candidate.Id) : await tasks.GetSourceAccessAsync(candidate.CookieId); if (access.Allowed) { item = candidate; isSourceProbe = access.IsProbe; if (item.Stage == VideoTaskItemStage.WaitingForSource) { item.Stage = VideoTaskItemStage.Pending; item.ErrorMessage = null; item.ErrorType = VideoTaskErrorType.None; item.RetryAfter = null; item.UpdatedAt = DateTime.Now; await db.Updateable(item).ExecuteCommandAsync(); } break; } if (candidate.Stage == VideoTaskItemStage.Pending) await tasks.WaitItemForSourceAsync(candidate.Id, candidate.CookieId, access.Message); } if (item == null) return false; var task = await db.Queryable().InSingleAsync(item.TaskId); if (task == null || task.Type == VideoTaskType.Redownload) return false; try { await tasks.EnsureTaskStorageAvailableAsync(task.Id); } catch (InvalidOperationException ex) { item.Stage = VideoTaskItemStage.WaitingForStorage; item.ErrorType = VideoTaskErrorType.StorageUnavailable; item.ErrorMessage = ex.Message; item.UpdatedAt = DateTime.Now; await db.Updateable(item).ExecuteCommandAsync(); await tasks.BlockTaskForStorageAsync(task.Id, ex.Message); await tasks.RefreshCountsAsync(task.Id); return true; } await tasks.StartTaskAsync(task.Id); try { if (string.IsNullOrWhiteSpace(item.RetrySnapshotJson)) throw new InvalidOperationException("该失败条目缺少可恢复快照,请重新执行对应的同步任务。"); var video = JsonConvert.DeserializeObject(item.RetrySnapshotJson) ?? throw new InvalidOperationException("任务缺少视频恢复快照。"); var cookie = await db.Queryable().InSingleAsync(video.CookieId) ?? throw new InvalidOperationException("任务所属抖音授权不存在。"); if (string.IsNullOrWhiteSpace(cookie.Cookies)) throw new InvalidOperationException("Cookie 无效,无法恢复下载。"); var storedUrls = DeserializeUrls(item.SourceUrlsJson); if (!string.IsNullOrWhiteSpace(video.VideoUrl) && !storedUrls.Contains(video.VideoUrl)) storedUrls.Insert(0, video.VideoUrl); var resolvedUrls = (await scope.ServiceProvider.GetRequiredService() .ResolveAsync(video, cookie, cancellationToken)).ToList(); var urls = (isSourceProbe ? resolvedUrls.Concat(storedUrls) : storedUrls.Concat(resolvedUrls)) .Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.Ordinal).Take(12).ToList(); if (urls.Count == 0) throw new InvalidOperationException("作品没有可用下载地址,可能已下架或设为私密。"); await tasks.SetItemStageAsync(item.Id, VideoTaskItemStage.Downloading, video.VideoSavePath); var storage = scope.ServiceProvider.GetRequiredService().Resolve(video.StorageType); var existingLength = await storage.GetLengthAsync(video.VideoSavePath, cancellationToken); if (!existingLength.HasValue || existingLength <= 0) { var downloaded = await scope.ServiceProvider.GetRequiredService() .DownloadToStorageAsync(video.StorageType, urls[0], video.VideoSavePath, cookie.Cookies, urls.Skip(1).ToList(), cancellationToken, Math.Min(12, urls.Count)); if (!downloaded.Success) { await tasks.MarkDownloadFailedAsync(item.Id, cookie.Id, downloaded); await CompleteWhenIdleAsync(db, tasks, task.Id); return true; } video.VideoSavePath = downloaded.ActualSavePath; await tasks.RecordSourceSuccessAsync(cookie.Id); } await tasks.SetItemStageAsync(item.Id, VideoTaskItemStage.Verifying, video.VideoSavePath); var length = await storage.GetLengthAsync(video.VideoSavePath, cancellationToken) ?? 0; if (length <= 0) throw new IOException("下载后的主媒体不存在或为空。"); if (item.ExpectedLength > 0 && length != item.ExpectedLength) throw new IOException($"下载长度不一致:期望 {item.ExpectedLength},实际 {length}。"); await tasks.SetItemStageAsync(item.Id, VideoTaskItemStage.Committing, video.VideoSavePath); video.FileSize = length; video.SyncTime = DateTime.Now; if (string.IsNullOrWhiteSpace(video.Id)) video.Id = Guid.NewGuid().ToString("N"); await db.Storageable(video).ExecuteCommandAsync(); var warning = "恢复任务仅保证主媒体,封面、头像或 NFO 将在后续同步补齐。"; var cleanupPending = false; string cleanupError = null; if (video.SupersededStorageSnapshot != null) { try { await scope.ServiceProvider.GetRequiredService() .CleanupSupersededLocalArtifactsAsync(video.SupersededStorageSnapshot, video.VideoSavePath); } catch (Exception cleanupException) { cleanupPending = true; cleanupError = cleanupException.GetBaseException().Message; warning += $" 旧本地文件清理失败,可在确认远端文件后稍后重试:{cleanupError}"; } } await tasks.MarkSucceededAsync(item.Id, length, video.Id, warning, cleanupPending, cleanupError); await CompleteWhenIdleAsync(db, tasks, task.Id); } catch (Exception ex) { await tasks.MarkFailedAsync(item.Id, ex); await CompleteWhenIdleAsync(db, tasks, task.Id); } return true; } private static async Task CompleteWhenIdleAsync(ISqlSugarClient db, VideoTaskService tasks, string taskId) { var remaining = await db.Queryable().Where(x => x.TaskId == taskId && (x.Stage == VideoTaskItemStage.Pending || x.Stage == VideoTaskItemStage.Downloading || x.Stage == VideoTaskItemStage.Verifying || x.Stage == VideoTaskItemStage.Committing || x.Stage == VideoTaskItemStage.WaitingForSource)).CountAsync(); if (remaining == 0) await tasks.CompleteTaskAsync(taskId); } private static List DeserializeUrls(string json) { try { return JsonConvert.DeserializeObject>(json) ?? new(); } catch { return new(); } } private static async Task BackfillLegacyRedownloadsAsync(IServiceProvider services) { var db = services.GetRequiredService(); var legacy = await db.Queryable().Where(x => string.IsNullOrEmpty(x.TaskId)).ToListAsync(); if (legacy.Count == 0) return; var tasks = services.GetRequiredService(); var task = await tasks.CreateTaskAsync(VideoTaskType.Redownload, VideoTaskTrigger.UpgradeRecovery, $"升级前重新下载({legacy.Count} 条)"); foreach (var job in legacy) { var video = !string.IsNullOrWhiteSpace(job.VideoRecordId) ? await db.Queryable().InSingleAsync(job.VideoRecordId) : await db.Queryable().Where(x => x.AwemeId == job.ViedoId).FirstAsync(); var item = await tasks.AddItemAsync(task.Id, job.ViedoId, job.CookieId, null, video?.ViedoType, video?.VideoTitle, video?.Author, job.SavePath, new[] { video?.VideoUrl }, video, video?.FileSize ?? 0); item.Stage = job.Status switch { 1 => VideoTaskItemStage.Succeeded, 2 => VideoTaskItemStage.Failed, _ => VideoTaskItemStage.Pending }; item.ErrorMessage = job.ErrorMessage; item.ErrorType = job.Status == 2 ? VideoTaskErrorType.Unknown : VideoTaskErrorType.None; item.CompletedAt = job.Status == 0 ? null : job.UpdateTime; item.UpdatedAt = job.UpdateTime == default ? DateTime.Now : job.UpdateTime; await db.Updateable(item).ExecuteCommandAsync(); job.TaskId = task.Id; job.TaskItemId = item.Id; } await db.Updateable(legacy).ExecuteCommandAsync(); await tasks.RefreshCountsAsync(task.Id); var pending = legacy.Count(x => x.Status == 0); if (pending == 0) await tasks.CompleteTaskAsync(task.Id); } } }