Files
douyin/service/VideoTaskService.cs

1554 lines
81 KiB
C#

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<VideoDownloadTask> 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<VideoDownloadTask>()
.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<VideoDownloadTaskItem>()
.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<VideoDownloadTask>()
.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<VideoDownloadTask>()
.SetColumns(x => new VideoDownloadTask
{
Status = VideoTaskStatus.WaitingForStorage,
ErrorMessage = message,
UpdatedAt = DateTime.Now
}).Where(x => x.Id == taskId).ExecuteCommandAsync();
}
public async Task<VideoDownloadTaskItem> AddItemAsync(
string taskId,
string awemeId,
string cookieId,
string cookieName,
VideoTypeEnum? videoType,
string title,
string author,
string targetPath = null,
IEnumerable<string> sourceUrls = null,
DouyinVideo retrySnapshot = null,
long expectedLength = 0,
bool workerManaged = false)
{
var existing = await _db.Queryable<VideoDownloadTaskItem>()
.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<VideoDownloadTaskItem>().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<VideoDownloadTask>()
.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<SourceFailureDisposition> 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<SourceAccessDecision> GetSourceAccessAsync(string cookieId)
{
if (string.IsNullOrWhiteSpace(cookieId))
return new SourceAccessDecision { Allowed = false, Message = "任务缺少所属抖音授权。" };
var cookie = await _db.Queryable<DouyinCookie>().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<SourceAccessDecision> TryAcquireSourceAsync(string cookieId, string itemId = null)
{
var access = await GetSourceAccessAsync(cookieId);
if (!access.Allowed || !access.IsProbe) return access;
var cookie = await _db.Queryable<DouyinCookie>().InSingleAsync(cookieId);
if (cookie.SourceProbeInProgress)
return new SourceAccessDecision { Allowed = false, RetryAt = cookie.SourceCooldownUntil, Message = "该授权正在执行来源恢复探测。" };
var oldest = await _db.Queryable<VideoDownloadTaskItem>()
.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<DouyinCookie>().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<VideoDownloadTaskItem>()
.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<VideoDownloadTask>()
.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<DouyinCookie>().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<VideoDownloadTaskItem>()
.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<VideoDownloadTask>().InSingleAsync(taskId);
if (task == null) return;
var items = await _db.Queryable<VideoDownloadTaskItem>().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<bool> 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<MediaStorageHealth> 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<VideoDownloadTask>()
.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<VideoDownloadTaskItem>()
.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<DouyinReDownload>()
.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<VideoTaskListPage> 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<UnifiedVideoTaskDto> 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<VideoTaskItemPage> 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<VideoDownloadTaskItem>().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<VideoTaskSummaryDto> 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<DouyinCookie>().ToListAsync()).Select(ToSourceHealthDto).ToList()
};
}
private async Task<List<UnifiedVideoTaskDto>> GetAllTaskDtosAsync()
{
var downloads = await _db.Queryable<VideoDownloadTask>().Where(x => !x.IsArchived).ToListAsync();
var migrations = await _db.Queryable<StorageMigrationTask>().Where(x => !x.IsArchived).ToListAsync();
var repairs = await _db.Queryable<OpenListDirectoryRepairTask>().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<VideoDownloadTaskItem>()
.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<DouyinReDownload>()
.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<DouyinReDownload>()
.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<DouyinCookie>()
.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<VideoDownloadTask>()
.Where(x => x.Status == VideoTaskStatus.Running).ToListAsync();
foreach (var task in active)
{
var items = await _db.Queryable<VideoDownloadTaskItem>().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<DouyinReDownload>()
.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<VideoDownloadTask>()
.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<VideoDownloadTaskItem>()
.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<VideoDownloadTask>()
.Where(x => x.Status == VideoTaskStatus.Queued && x.UpdatedAt < cutoff).ToListAsync();
foreach (var task in stale)
{
if (await _db.Queryable<VideoDownloadTaskItem>().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<VideoDownloadTaskItem>().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<DouyinReDownload>().Where(x =>
(x.Status == 1 && x.UpdateTime < successBefore) || (x.Status == 2 && x.UpdateTime < failedBefore)).ExecuteCommandAsync();
var finalMigrationIds = await _db.Queryable<StorageMigrationTask>()
.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<StorageMigrationItem>().Where(x => finalMigrationIds.Contains(x.TaskId)).ExecuteCommandAsync();
}
private async Task RecordStorageFailureAsync(string taskId, string message)
{
var task = await _db.Queryable<VideoDownloadTask>().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<VideoDownloadTask>()
.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<VideoDownloadTaskItem>()
.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<VideoDownloadTask>()
.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<SourceFailureDisposition> RecordSourceFailureAsync(string cookieId, MediaDownloadResult result)
{
if (string.IsNullOrWhiteSpace(cookieId)) return SourceFailureDisposition.Continue;
var cookie = await _db.Queryable<DouyinCookie>().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<VideoDownloadTaskItem>()
.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<VideoDownloadTaskItem>()
.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<VideoDownloadTaskItem>().InSingleAsync(currentItemId);
if (current != null) active.Add(current);
}
var taskIds = active.Select(x => x.TaskId).Distinct().ToList();
var tasks = taskIds.Count == 0
? new List<VideoDownloadTask>()
: await _db.Queryable<VideoDownloadTask>().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<DouyinReDownload>()
.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<VideoDownloadTask>().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<MediaStorageHealth> GetStorageHealthForTargetAsync(MediaStorageType type, string fingerprint)
{
fingerprint ??= string.Empty;
var health = await _db.Queryable<MediaStorageHealth>()
.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<DouyinCookie>().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<string> 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<DouyinCookie>().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<VideoDownloadTask> RequireTaskAsync(string id) =>
await _db.Queryable<VideoDownloadTask>().InSingleAsync(id) ?? throw new KeyNotFoundException("任务不存在。");
private async Task<VideoDownloadTaskItem> RequireItemAsync(string id) =>
await _db.Queryable<VideoDownloadTaskItem>().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<string> { "retry-failed" } : new List<string>()
};
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<string> MigrationActions(StorageMigrationTask task)
{
var actions = new List<string>();
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<string> RepairActions(OpenListDirectoryRepairTask task)
{
var actions = new List<string>();
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 => "存储维护",
_ => "视频任务"
};
}
}