223 lines
12 KiB
C#
223 lines
12 KiB
C#
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<VideoTaskService>();
|
|
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<VideoTaskService>().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<bool> ProcessOneAsync(CancellationToken cancellationToken)
|
|
{
|
|
using var scope = _scopeFactory.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
|
|
var tasks = scope.ServiceProvider.GetRequiredService<VideoTaskService>();
|
|
var candidates = await db.Queryable<VideoDownloadTaskItem>()
|
|
.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<VideoDownloadTask>().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<DouyinVideo>(item.RetrySnapshotJson)
|
|
?? throw new InvalidOperationException("任务缺少视频恢复快照。");
|
|
var cookie = await db.Queryable<DouyinCookie>().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<DouyinMigrationSourceResolver>()
|
|
.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<MediaStorageRouter>().Resolve(video.StorageType);
|
|
var existingLength = await storage.GetLengthAsync(video.VideoSavePath, cancellationToken);
|
|
if (!existingLength.HasValue || existingLength <= 0)
|
|
{
|
|
var downloaded = await scope.ServiceProvider.GetRequiredService<DouyinHttpClientService>()
|
|
.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<DouyinVideoService>()
|
|
.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<VideoDownloadTaskItem>().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<string> DeserializeUrls(string json)
|
|
{
|
|
try { return JsonConvert.DeserializeObject<List<string>>(json) ?? new(); }
|
|
catch { return new(); }
|
|
}
|
|
|
|
private static async Task BackfillLegacyRedownloadsAsync(IServiceProvider services)
|
|
{
|
|
var db = services.GetRequiredService<ISqlSugarClient>();
|
|
var legacy = await db.Queryable<DouyinReDownload>().Where(x => string.IsNullOrEmpty(x.TaskId)).ToListAsync();
|
|
if (legacy.Count == 0) return;
|
|
var tasks = services.GetRequiredService<VideoTaskService>();
|
|
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<DouyinVideo>().InSingleAsync(job.VideoRecordId)
|
|
: await db.Queryable<DouyinVideo>().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);
|
|
}
|
|
}
|
|
}
|