1554 lines
81 KiB
C#
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 => "存储维护",
|
|
_ => "视频任务"
|
|
};
|
|
}
|
|
}
|