Files
douyin/job/DouyinBasicSyncJob.cs
T

2103 lines
107 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClockSnowFlake;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.model.response;
using dy.net.service;
using dy.net.utils;
using Newtonsoft.Json;
using Quartz;
using Quartz.Util;
using Serilog;
using System.Net;
using System.Text;
using dy.net.storage;
namespace dy.net.job
{
/// <summary>
/// 抖音数据同步任务基类
/// 所有具体的抖音同步任务(如收藏、关注、作品等)都应继承此类
/// 提供了通用的同步逻辑,如Cookie处理、视频下载、数据存储等
/// </summary>
[DisallowConcurrentExecution] // 禁止并发执行,确保同一时间只有一个实例在运行
public abstract class DouyinBasicSyncJob : IJob, IDisposable
{
#region 受保护字段
/// <summary>
/// 抖音Cookie服务,用于获取和管理用户Cookie
/// </summary>
protected readonly DouyinCookieService douyinCookieService;
/// <summary>
/// 抖音HTTP客户端服务,用于发送HTTP请求
/// </summary>
protected readonly DouyinHttpClientService douyinHttpClientService;
/// <summary>
/// 抖音视频服务,用于视频信息的数据库操作
/// </summary>
protected readonly DouyinVideoService douyinVideoService;
/// <summary>
/// 抖音通用服务,用于获取应用配置等
/// </summary>
protected readonly DouyinCommonService douyinCommonService;
protected readonly MediaStorageRouter mediaStorageRouter;
protected readonly VideoTaskService videoTaskService;
protected StorageType ActiveStorageType { get; private set; } = StorageType.Local;
private string _taskId;
private string _currentTaskItemId;
private bool _stopCurrentCookie;
private readonly List<string> _currentItemWarnings = new();
/// <summary>
/// 抖音关注列表
/// </summary>
private readonly DouyinFollowService douyinFollowService;
/// <summary>
/// 图文合成视频
/// </summary>
private readonly DouyinMergeVideoService douyinMergeVideoService;
/// <summary>
/// 收藏夹、短剧、合集
/// </summary>
private readonly DouyinCollectCateService douyinCollectCateService;
/// <summary>
/// 随机数生成器,用于生成随机延迟,模拟人类操作
/// </summary>
protected readonly Random _random = new Random();
/// <summary>
/// 每页请求的视频数量.不可修改
/// </summary>
protected string count = "18";
private bool disposedValue;
#endregion
#region 私有字段
// 类级别静态字段:映射视频类型与同步完成状态判断逻辑(复用+易维护)
//private static readonly Dictionary<VideoTypeEnum, Func<DouyinCookie, bool>> _syncStatusCheckMap = new()
//{
// [VideoTypeEnum.dy_favorite] = cookie => cookie.FavHasSyncd == 1,
// [VideoTypeEnum.dy_collects] = cookie => cookie.CollHasSyncd == 1,
// [VideoTypeEnum.dy_follows] = cookie => cookie.UperSyncd == 1,
// [VideoTypeEnum.ImageVideo] = cookie => cookie.UperSyncd == 1
// && cookie.CollHasSyncd == 1
// && cookie.FavHasSyncd == 1
//};
#endregion
#region 抽象属性
/// <summary>
/// 同步类型
/// </summary>
protected abstract VideoTypeEnum VideoType { get; }
#endregion
#region 构造函数
/// <summary>
/// 初始化 <see cref="DouyinBasicSyncJob"/> 类的新实例
/// </summary>
/// <param name="douyinCookieService">抖音Cookie服务</param>
/// <param name="douyinHttpClientService">抖音HTTP客户端服务</param>
/// <param name="douyinVideoService">抖音视频服务</param>
/// <param name="douyinCommonService">抖音通用服务</param>
/// <param name="douyinFollowService">抖音关注的</param>
/// <param name="douyinMergeVideoService">视频合成</param>
/// <param name="douyinCollectCateService"></param>
protected DouyinBasicSyncJob(
DouyinCookieService douyinCookieService,
DouyinHttpClientService douyinHttpClientService,
DouyinVideoService douyinVideoService,
DouyinCommonService douyinCommonService,
DouyinFollowService douyinFollowService,
DouyinMergeVideoService douyinMergeVideoService,
DouyinCollectCateService douyinCollectCateService,
MediaStorageRouter mediaStorageRouter,
VideoTaskService videoTaskService)
{
this.douyinCookieService = douyinCookieService ?? throw new ArgumentNullException(nameof(douyinCookieService));
this.douyinHttpClientService = douyinHttpClientService ?? throw new ArgumentNullException(nameof(douyinHttpClientService));
this.douyinVideoService = douyinVideoService ?? throw new ArgumentNullException(nameof(douyinVideoService));
this.douyinCommonService = douyinCommonService ?? throw new ArgumentNullException(nameof(douyinCommonService));
this.douyinFollowService = douyinFollowService;
this.douyinMergeVideoService = douyinMergeVideoService;
this.douyinCollectCateService = douyinCollectCateService;
this.mediaStorageRouter = mediaStorageRouter;
this.videoTaskService = videoTaskService;
}
#endregion
#region 公共方法
/// <summary>
/// 执行任务的主入口点
/// 由Quartz调度器调用,负责协调整个同步流程
/// </summary>
/// <param name="context">作业执行上下文</param>
/// <returns>一个表示异步操作的任务</returns>
public async Task Execute(IJobExecutionContext context)
{
// 获取应用配置
var config = douyinCommonService.GetConfig();
ActiveStorageType = config?.StorageType ?? StorageType.Local;
var (taskId, trigger) = ResolveTaskContext(context.MergedJobDataMap);
if (string.IsNullOrWhiteSpace(taskId))
taskId = (await videoTaskService.CreateTaskAsync(VideoTaskType.Sync, trigger,
$"{VideoType.GetDesc()}同步", VideoType, ActiveStorageType)).Id;
_taskId = taskId;
if (!await videoTaskService.IsStorageAvailableAsync())
{
await videoTaskService.BlockTaskForStorageAsync(_taskId, "存储不可用,请在任务中心重新检测存储。");
return;
}
await videoTaskService.StartTaskAsync(_taskId);
string taskError = null;
try
{
// 在处理Cookie之前执行的预处理
await BeforeProcessCookies();
// 获取所有有效的Cookie
var cookies = await GetSyncCookies();
if (cookies == null || !cookies.Any())
{
Log.Debug($"[{VideoType.GetDesc()}]-Cookie无效或同步开关未开启或对应类型的存储路径未设置,请检查...");
return;
}
Log.Debug($"[{VideoType.GetDesc()}]共发现{cookies.Count}个有效Cookie,同步开始...");
// 遍历每个有效的Cookie,执行同步
foreach (var cookie in cookies)
{
if (!await videoTaskService.IsStorageAvailableAsync()) break;
var sourceAccess = await videoTaskService.GetSourceAccessAsync(cookie.Id);
if (!sourceAccess.Allowed)
{
Log.Warning("[{Cookie}][{Type}]跳过本次同步:{Reason}", cookie.UserName, VideoType.GetDesc(), sourceAccess.Message);
continue;
}
await ProcessSyncUserCookie(cookie, config);
}
}
catch (Exception ex)
{
taskError = ex.GetBaseException().Message;
Log.Error(ex, "[{Type}]同步任务执行失败,TaskId={TaskId}Trigger={Trigger}",
VideoType.GetDesc(), _taskId, trigger);
throw;
}
finally
{
await videoTaskService.CompleteTaskAsync(_taskId, taskError);
}
}
internal static (string TaskId, VideoTaskTrigger Trigger) ResolveTaskContext(JobDataMap jobData)
{
string taskId = null;
string triggerName = null;
jobData?.TryGetString("video-task-id", out taskId);
jobData?.TryGetString("video-task-trigger", out triggerName);
var trigger = string.Equals(triggerName, "manual", StringComparison.OrdinalIgnoreCase)
? VideoTaskTrigger.Manual
: VideoTaskTrigger.Scheduled;
return (taskId, trigger);
}
internal static bool ShouldReplaceOldStorageRecord(DouyinVideo existing, StorageType activeStorageType) =>
activeStorageType.IsRemote() && existing != null && existing.StorageType != activeStorageType;
#endregion
#region 受保护方法
/// <summary>
/// 在处理Cookie之前执行的预处理操作-AOP
/// </summary>
/// <returns>一个表示异步操作的任务</returns>
protected virtual Task BeforeProcessCookies() => Task.CompletedTask;
/// <summary>
/// 获取所有有效的Cookie
/// 子类必须实现此方法,根据具体任务类型筛选有效的Cookie
/// </summary>
/// <returns>有效的Cookie列表</returns>
protected virtual async Task<List<DouyinCookie>> GetSyncCookies()
{
var cookies = await douyinCookieService.GetOpendCookiesAsync();
return cookies.Where(x => !string.IsNullOrWhiteSpace(GetStorageRoot(x, VideoType))).ToList();
}
protected string GetStorageRoot(DouyinCookie cookie, VideoTypeEnum type)
{
if (ActiveStorageType.IsRemote())
return StorageMigrationPathPolicy.GetRemoteRoot(cookie, type);
return type switch
{
VideoTypeEnum.dy_favorite => cookie.FavSavePath,
VideoTypeEnum.dy_follows => cookie.UpSavePath,
VideoTypeEnum.dy_mix => string.IsNullOrWhiteSpace(cookie.MixPath) ? cookie.SavePath : cookie.MixPath,
VideoTypeEnum.dy_series => string.IsNullOrWhiteSpace(cookie.SeriesPath) ? cookie.SavePath : cookie.SeriesPath,
_ => cookie.SavePath
};
}
protected string CombineStoragePath(params string[] parts) => ActiveStorageType.IsRemote()
? StoragePath.CombineRemote(parts)
: Path.Combine(parts);
protected void EnsureStorageDirectory(string path)
{
if (ActiveStorageType == StorageType.Local && !Directory.Exists(path)) Directory.CreateDirectory(path);
}
protected StorageMigrationPathPlan BuildRemotePathPlan(
DouyinCookie cookie,
Aweme item,
AppConfig config,
DouyinFollowed followed,
DouyinCollectCate cate) =>
StorageMigrationPathPolicy.Build(item, VideoType, cookie, cate, followed, config);
private static string CreateTemporaryDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "dysync", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
private async Task<string> UploadLocalFileAsync(string localPath, string remotePath)
{
var storage = mediaStorageRouter.Resolve(ActiveStorageType);
if (storage is ICanonicalMediaStorage canonicalStorage)
remotePath = await canonicalStorage.CanonicalizePathAsync(remotePath);
await using var input = new FileStream(localPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, true);
await storage.WriteAsync(remotePath, input, input.Length, null);
return remotePath;
}
private async Task<IReadOnlyDictionary<string, string>> UploadDirectoryAsync(string localRoot, string remoteRoot)
{
var uploaded = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var file in Directory.EnumerateFiles(localRoot, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(localRoot, file).Replace('\\', '/');
uploaded[relative] = await UploadLocalFileAsync(file, StoragePath.CombineRemote(remoteRoot, relative));
}
return uploaded;
}
private static void CleanupTemporaryDirectory(string path)
{
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path)) return;
try { Directory.Delete(path, true); }
catch (Exception ex) { Log.Warning(ex, "清理临时目录失败:{Path}", path); }
}
private async Task WriteRemoteNfoAsync(AppConfig config, DouyinVideo video, DouyinCollectCate cate)
{
if (config.CloseNfo || !video.StorageType.IsRemote()) return;
foreach (var file in NfoContentBuilder.Build(video, cate?.Name))
{
var bytes = Encoding.UTF8.GetBytes(file.Value);
await using var content = new MemoryStream(bytes, false);
await mediaStorageRouter.Resolve(video.StorageType).WriteAsync(file.Key, content, bytes.Length, "application/xml");
}
}
/// <summary>
/// 根据Cookie和游标获取视频数据
/// 子类必须实现此方法,调用具体的API接口获取视频列表
/// </summary>
/// <param name="cookie">用户Cookie</param>
/// <param name="cursor">分页游标,用于获取下一页数据</param>
/// <param name="followed">关注的人</param>
/// <param name="cate">自定义收藏夹、合集、短剧</param>
/// <returns>视频信息对象,包含视频列表和分页信息</returns>
protected abstract Task<DouyinVideoInfoResponse> FetchVideoData(DouyinCookie cookie, string cursor, DouyinFollowed followed, DouyinCollectCate cate);
/// <summary>
/// 获取下一页数据的游标
/// </summary>
/// <param name="data">当前获取到的视频数据</param>
/// <returns>下一页数据的游标</returns>
private static string GetNextCursor(DouyinVideoInfoResponse data)
{
return data?.Cursor ?? (data?.MaxCursor ?? "0");
}
/// <summary>
/// 创建视频保存文件夹
/// </summary>
/// <param name="cookie">用户Cookie</param>
/// <param name="item">视频信息</param>
/// <param name="followed">关注用户</param>
/// <param name="cate"></param>
/// <param name="config">应用配置</param>
/// <returns>创建的视频保存文件夹路径</returns>
protected virtual string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (ActiveStorageType.IsRemote())
return BuildRemotePathPlan(cookie, item, config, followed, cate).DirectoryPath;
var subFolder = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true);
var root = GetStorageRoot(cookie, VideoType);
var folder = CombineStoragePath(root, subFolder);
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
else
{
//说明文件夹存在,检查里面有没有文件,如果已经有视频文件了,说明视频标题相同,那么应该重新创建文件夹,+id
folder = CombineStoragePath(root, subFolder + "_" + item.AwemeId);
}
return folder;
}
/// <summary>
/// 获取视频文件名,默认就用id作文件名
/// </summary>
/// <param name="cookie">用户Cookie</param>
/// <param name="item">视频信息</param>
/// <param name="config">配置信息</param>
/// <param name="cate"></param>
/// <returns>生成的视频文件名</returns>
protected virtual string GetVideoFileName(DouyinCookie cookie, Aweme item, AppConfig config, DouyinCollectCate cate)
{
if (ActiveStorageType.IsRemote())
return Path.GetFileName(BuildRemotePathPlan(cookie, item, config, null, cate).VideoPath);
if (cate != null && cate.CateType == VideoTypeEnum.dy_custom_collect)
{
if (item.Video != null && item.Video.BitRate != null)
return $"{item.AwemeId}.{item.Video.BitRate.FirstOrDefault().Format}";
return $"{item.AwemeId}.mp4";
}
else
{
if ((VideoType == VideoTypeEnum.dy_series || VideoType == VideoTypeEnum.dy_mix) && item.MixInfo?.Statis?.CurrentEpisode != null)
{
// 第一步:将 CurrentEpisode 转换为整数(兼容字符串/数字类型)
if (int.TryParse(item.MixInfo.Statis.CurrentEpisode.ToString(), out int episodeNum))
{
// 第二步:格式化数字,确保 1-9 补 0,10+ 保持原样
string episodeStr = episodeNum.ToString("D2");
return $"S01E{episodeStr}.mp4";
}
// 容错:如果转换失败,使用原始值(避免程序报错)
return $"S01E{item.MixInfo.Statis.CurrentEpisode}.mp4";
}
else
{
string Format = "mp4";
string FileHash = "";
string Height = "";
string Width = "";
if (item.Video != null && item.Video.BitRate != null)
{
var bitrate = item.Video.BitRate.FirstOrDefault();
Format = bitrate.Format;
FileHash = bitrate.PlayAddr.FileHash;
Height = bitrate.PlayAddr.Height.ToString();
Width = bitrate.PlayAddr.Width.ToString();
}
else
{
//图片合成视频,参数要自己写。
var image = item.Images?.FirstOrDefault();
if (image != null)
{
FileHash = IdGener.GetGuid().ToLower().Replace("-", "");//使用随机值,避免重复
Height = image.Height.ToString();
Width = image.Width.ToString();
}
}
string fileName;
//if (config?.UperUseViedoTitle ?? false)//优先
//{
// var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId);
// var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName);
// fileName = string.IsNullOrWhiteSpace(existingName) ? $"{sampleName}.{Format}" : $"{existingName}.{Format}";
//}
//else
//{
if (!string.IsNullOrWhiteSpace(config.FullFollowedTitleTemplate))
{
var fullName = VideoTitleGenerator.Generate(config.FullFollowedTitleTemplate, new VideoTitleDataTemplate
{
FileHash = FileHash,
Id = item.AwemeId,
ReleaseTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
Resolution = $"{Width}×{Height}",
VideoTitle = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId),
Author = item.Author.Nickname
});
fileName = $"{fullName}.{Format}";
}
else
{
fileName = $"{item.AwemeId}.{Format}";
}
//}
return fileName;
}
}
}
/// <summary>
/// 获取作者头像保存的基础路径
/// 子类必须实现此方法,指定头像的存储位置
/// </summary>
/// <param name="cookie">用户Cookie</param>
/// <returns>作者头像保存的基础路径</returns>
protected abstract string GetAuthorAvatarBasePath(DouyinCookie cookie);
/// <summary>
/// 处理同步完成后的操作
/// </summary>
/// <param name="cookie">用户Cookie</param>
/// <param name="syncCount">本次同步成功的视频数量</param>
/// <param name="followed"></param>
/// <param name="cate"></param>
/// <returns>一个表示异步操作的任务</returns>
protected async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount, DouyinFollowed followed = null, DouyinCollectCate cate = null)
{
var tag = cate?.Name ?? followed?.UperName ?? string.Empty;
tag = !string.IsNullOrWhiteSpace(tag) ? $"-[{tag}]" : tag;
if (VideoType != VideoTypeEnum.dy_custom_collect || cookie.UseCollectFolder)
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]{tag},本次成功同步{syncCount}条视频");
}
if (cate != null)
{
//更新合集短剧完结状态
await douyinCollectCateService.UpdateCate2EndStatus(cate);
}
}
/// <summary>
/// 处理单个用户Cookie的同步逻辑
/// 负责循环获取视频数据、处理视频、保存视频信息等
/// </summary>
/// <param name="cookie">用户Cookie</param>
/// <param name="config">应用配置</param>
/// <returns>一个表示异步操作的任务</returns>
protected async Task ProcessSyncUserCookie(DouyinCookie cookie, AppConfig config)
{
_stopCurrentCookie = false;
switch (VideoType)
{
case VideoTypeEnum.dy_follows:
if (cookie.DownFollowd)
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]开始同步...");
// 查询关注列表开启了同步的关注。单个博主失败不能阻断后续博主。
var follows = await douyinFollowService.GetSyncFollows(cookie.MyUserId);
if (follows != null && follows.Any())
{
var failures = new List<string>();
var consecutiveFailures = 0;
for (var index = 0; index < follows.Count; index++)
{
var followed = follows[index];
if (_stopCurrentCookie) break;
try
{
int syncCount = 0; // 本次同步成功的视频数量
string cursor = "0";
bool hasMore = true;
(syncCount, cursor, hasMore) = await GetAndSaveViedos(cookie, config, syncCount, cursor, hasMore, followed);
await HandleSyncCompletion(cookie, syncCount, followed);
consecutiveFailures = 0;
}
catch (Exception ex)
{
consecutiveFailures++;
var reason = ex.GetBaseException().Message;
failures.Add($"{followed?.UperName ?? followed?.SecUid ?? "未知博主"}{reason}");
Log.Error(ex, "[{Cookie}][{Type}][{Author}]获取或处理作品失败,继续下一个博主",
cookie.UserName, VideoType.GetDesc(), followed?.UperName ?? followed?.SecUid);
if (consecutiveFailures >= 3)
{
var remaining = follows.Count - index - 1;
failures.Add($"连续 3 个博主获取失败,本轮提前结束,尚有 {remaining} 个博主未扫描");
break;
}
}
}
if (failures.Count > 0)
throw new InvalidOperationException(
$"关注作品扫描有异常:{string.Join("", failures.Take(4))}" +
(failures.Count > 4 ? $";另有 {failures.Count - 4} 项" : string.Empty));
}
}
else
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]同步未开启");
}
break;
case VideoTypeEnum.dy_favorite:
if (cookie.DownFavorite)
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]开始同步...");
int syncCount = 0;
string cursor = "0";
bool hasMore = true;
(syncCount, cursor, hasMore) = await GetAndSaveViedos(cookie, config, syncCount, cursor, hasMore);
await HandleSyncCompletion(cookie, syncCount);
}
else
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]同步未开启");
}
break;
case VideoTypeEnum.dy_collects:
if (cookie.UseCollectFolder)
{
Log.Debug($"[{VideoType.GetDesc()}]-已开启自定义收藏夹同步...break;");
}
else if (cookie.DownCollect)
{
int syncCount = 0;
string cursor = "0";
bool hasMore = true;
(syncCount, cursor, hasMore) = await GetAndSaveViedos(cookie, config, syncCount, cursor, hasMore);
await HandleSyncCompletion(cookie, syncCount);
}
else
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]同步未开启");
}
break;
case VideoTypeEnum.dy_mix:
if (cookie.DownMix)
{
await SyncCustomListVideos(cookie, config);
}
else
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]同步未开启");
}
break;
case VideoTypeEnum.dy_series:
if (cookie.DownSeries)
await SyncCustomListVideos(cookie, config);
else
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]同步未开启");
}
break;
case VideoTypeEnum.dy_custom_collect:
if (cookie.UseCollectFolder)
await SyncCustomListVideos(cookie, config);
else
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]同步未开启");
}
break;
case VideoTypeEnum.ImageVideo:
default:
break;
}
}
/// <summary>
/// 同步下载自定义收藏夹、合集、短剧
/// </summary>
/// <param name="cookie"></param>
/// <param name="config"></param>
/// <returns></returns>
private async Task SyncCustomListVideos(DouyinCookie cookie, AppConfig config)
{
var cates = await douyinCollectCateService.GetSyncCates(cookie.Id, VideoType);
if (cates != null && cates.Any())
{
foreach (var cate in cates)
{
if (_stopCurrentCookie) break;
int syncCount = 0; // 本次同步成功的视频数量
string cursor = "0";
bool hasMore = true;
(syncCount, cursor, hasMore) = await GetAndSaveViedos(cookie, config, syncCount, cursor, hasMore, null, cate);
await HandleSyncCompletion(cookie, syncCount, null, cate);
}
}
else
{
Serilog.Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]没有查询到已开启的对象");
}
}
private async Task<(int syncCount, string cursor, bool hasMore)> GetAndSaveViedos(DouyinCookie cookie, AppConfig config, int syncCount, string cursor, bool hasMore, DouyinFollowed followed = null, DouyinCollectCate cate = null)
{
// 循环获取视频数据
while (hasMore && !_stopCurrentCookie)
{
// 获取视频数据
var data = await FetchVideoData(cookie, cursor, followed, cate);
if (data == null || data.AwemeList == null || !data.AwemeList.Any())
{
Serilog.Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}][{cate?.Name}] 没有新的视频");
break;
}
if (data.StatusCode == 0 && cookie.StatusMsg != "正常")
{
cookie.StatusCode = data.StatusCode;
cookie.StatusMsg = "正常";
await douyinCookieService.UpdateAsync(cookie);
}
// 判断是否还有更多数据
//hasMore = ShouldContinueSync(cookie, data, followed, config);
// 获取下一页游标
cursor = GetNextCursor(data);
hasMore = data.HasMore == 1;
// 处理视频列表
(List<DouyinVideo> videos, int syncCountx) = await ProcessVideoList(syncCount, cookie, data, config, followed, cate);
if (videos != null && videos.Any())
{
// 保存视频信息到数据库
await SaveVideos(cookie, videos);
videos.Clear();
}
syncCount += syncCountx;
if (IsSyncLimitReached(cookie, config, syncCount, cate, followed))
{
break;
}
//随机等待
await Task.Delay(_random.Next(2, 10) * 1000);
}
return (syncCount, cursor, hasMore);
}
/// <summary>
/// 检查是否达到同步批次上限且满足状态条件,需要终止循环
/// </summary>
/// <param name="cookie">抖音Cookie</param>
/// <param name="config">同步配置</param>
/// <param name="syncCount">已同步数量</param>
/// <param name="cate"></param>
/// <param name="followed"></param>
/// <returns>是否需要终止循环</returns>
private bool IsSyncLimitReached(DouyinCookie cookie, AppConfig config, int syncCount, DouyinCollectCate cate, DouyinFollowed followed)
{
if (cate != null && cate.CateType != VideoTypeEnum.dy_custom_collect)
{
if (syncCount >= 30)
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]:本次同步数量{syncCount},等下次任务继续同步");
return true;
}
}
else
{
if (syncCount >= config.BatchCount)
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]:本次同步数量{syncCount},已达配置上限{config.BatchCount},等下次任务继续同步");
return true;
}
}
if (VideoType == VideoTypeEnum.dy_collects || VideoType == VideoTypeEnum.dy_favorite)
return config.OnlySyncNew;
return VideoType == VideoTypeEnum.dy_follows && !followed.FullSync;
//// 获取当前视频类型对应的状态判断逻辑
//if (!_syncStatusCheckMap.TryGetValue(VideoType, out var isSyncCompleted))
//{
// //Log.Debug($"[{VideoType.GetVideoTypeDesc()}]无匹配的同步状态判断规则,不终止循环");
// return false;
//}
//// 状态满足则记录日志并返回「终止循环」
//if (isSyncCompleted.Invoke(cookie))
//{
// Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]本次同步达到上限{config.BatchCount},停止同步!!!");
// return true;
//}
}
/// <summary>
/// 遍历视频列表,分别处理每个视频和图片集
/// </summary>
/// <param name="syncCount1"></param>
/// <param name="cookie">用户Cookie</param>
/// <param name="data">视频信息对象</param>
/// <param name="config">应用配置</param>
/// <param name="followed">关注的</param>
/// <param name="cate">收藏夹、合集、短剧</param>
/// <returns>处理后的视频实体列表</returns>
protected async Task<(List<DouyinVideo> videos, int currentCount)> ProcessVideoList(int syncCount1, DouyinCookie cookie, DouyinVideoInfoResponse data, AppConfig config, DouyinFollowed followed = null, DouyinCollectCate cate = null)
{
int syncCount = 0;
var videos = new List<DouyinVideo>();
foreach (var item in data.AwemeList)
{
// Stop promptly once the shared storage circuit opens. Pending work is retained in the
// task center and can be retried after an explicit successful storage probe.
if (!await videoTaskService.IsStorageAvailableAsync()) break;
if (_stopCurrentCookie) break;
_currentTaskItemId = null;
_currentItemWarnings.Clear();
var trackedAwemeId = string.IsNullOrWhiteSpace(item?.AwemeId)
? $"unknown-{Guid.NewGuid():N}"
: item.AwemeId;
try
{
//if (item.AwemeId != "7321309610927770930")
//{
// continue;
//}
//if (!item.Desc.Contains("抖音各种隐藏功能,每个都好用到离谱#抖音#隐藏功能"))
//{
// continue;
//}
//判断视频是否是强制删除且不再下载的视频
var deleteVideo = await douyinCommonService.GetDeleteVideoAsync(item.AwemeId);
if (deleteVideo != null)
{
await videoTaskService.MarkSkippedAsync(_taskId, item.AwemeId, cookie.Id, cookie.UserName, VideoType,
item.Desc, item.Author?.Nickname, VideoTaskSkipReason.PermanentlyExcluded,
"视频已被永久排除,可在任务中心取消排除。", deleteVideo.Id);
continue;
}
// 查询数据库中是否已存在该视频(通过 AwemeId 唯一标识)
var exitVideo = await douyinVideoService.GetByAwemeId(item.AwemeId);
var replaceOldStorage = ShouldReplaceOldStorageRecord(exitVideo, ActiveStorageType);
// 当前远端存储与现有记录不一致时,不能让普通去重阻止存储收敛。
// 旧记录和旧文件会一直保留到新媒体写入、校验和数据库提交全部成功之后。
bool Goon = replaceOldStorage || await AutoDistinct(config, exitVideo, cookie);
if (!Goon)
{
await videoTaskService.MarkSkippedAsync(_taskId, item.AwemeId, cookie.Id, cookie.UserName, VideoType,
item.Desc, item.Author?.Nickname, VideoTaskSkipReason.Deduplicated, "已存在更高或相同优先级的视频记录。");
continue;
}
if (exitVideo != null)
{
if (replaceOldStorage)
{
Log.Debug("[{Cookie}][{Type}]作品 {AwemeId} 位于 {OldStorage},本次同步将安全替换到 {NewStorage}。",
cookie.UserName, VideoType.GetDesc(), item.AwemeId, exitVideo.StorageType, ActiveStorageType);
}
//文件存在
else if (await mediaStorageRouter.Resolve(exitVideo.StorageType).ExistsAsync(exitVideo.VideoSavePath))
{
//如果当前时正在下载关注列表的视频,但是已经存在合集下载过了,那么跳过
if (VideoType == VideoTypeEnum.dy_follows && exitVideo.ViedoType == VideoTypeEnum.dy_mix)
{
await videoTaskService.MarkSkippedAsync(_taskId, item.AwemeId, cookie.Id, cookie.UserName, VideoType,
item.Desc, item.Author?.Nickname, VideoTaskSkipReason.AlreadyExists, "合集版本已存在,关注同步已跳过。");
continue;
}
else
{
//如果当前正在下载合集视频,但是发现已经存在了,但视频类型不是合集,那么删掉原来的记录以及文件,重新下载,合集优先级最高
if (VideoType == VideoTypeEnum.dy_mix && exitVideo.ViedoType != VideoTypeEnum.dy_mix)
{
if (exitVideo.StorageType.IsRemote())
await StorageArtifactCleaner.DeleteWebDavArtifactsAsync(
mediaStorageRouter.Resolve(exitVideo.StorageType), exitVideo);
else
await mediaStorageRouter.Resolve(StorageType.Local).DeleteAsync(exitVideo.VideoSavePath);
await douyinVideoService.DeleteById(exitVideo.Id);
}
else
{
await douyinVideoService.DeleteById(exitVideo.Id);
}
}
}
else
{
//2026-06-16 21:56:40 bug修复 ,之前会一直重复下载这个图片
//判断如果文件路径="/" 说明是仅下载图片的记录,不处理,直接跳过也不用删除记录
if(exitVideo.VideoUrl=="/"&& exitVideo.IsMergeVideo == 1)
{
await videoTaskService.MarkSkippedAsync(_taskId, item.AwemeId, cookie.Id, cookie.UserName, VideoType,
item.Desc, item.Author?.Nickname, VideoTaskSkipReason.AlreadyExists, "仅图片或音频记录已存在。");
continue;
}
//文件不存在,直接删掉原始记录
await douyinVideoService.DeleteById(exitVideo.Id);
}
}
var uper = await douyinFollowService.GetByUperId(item.AuthorUserId.ToString(), cookie.MyUserId);
if (uper != null && uper.FullSync)
{
followed ??= uper;
}
var selectedBitRate = GetBestMatchedVideoUrl(item, config);
var sourceUrls = GetMediaCandidates(item, selectedBitRate);
var trackedItem = await videoTaskService.AddItemAsync(_taskId, item.AwemeId, cookie.Id, cookie.UserName,
VideoType, item.Desc, item.Author?.Nickname, sourceUrls: sourceUrls,
expectedLength: item.Video?.BitRate?.FirstOrDefault()?.PlayAddr?.DataSize ?? 0);
_currentTaskItemId = trackedItem.Id;
var sourceAccess = await videoTaskService.TryAcquireSourceAsync(cookie.Id, trackedItem.Id);
if (!sourceAccess.Allowed)
{
await videoTaskService.WaitItemForSourceAsync(trackedItem.Id, cookie.Id, sourceAccess.Message);
_stopCurrentCookie = true;
_currentTaskItemId = null;
break;
}
// 处理单个视频
DouyinVideo video;
try { video = await ProcessSingleVideo(cookie, item, config, followed, cate, replaceOldStorage ? exitVideo : null); }
catch (Exception ex)
{
await videoTaskService.MarkFailedAsync(_currentTaskItemId, ex);
Log.Error(ex, "[{Cookie}][{Type}]处理视频失败:{AwemeId}", cookie.UserName, VideoType.GetDesc(), item.AwemeId);
continue;
}
if (video != null)
{
if (replaceOldStorage) video.SupersededStorageSnapshot = exitVideo;
videos.Add(video);
syncCount++;
if (syncCount + syncCount1 >= config.BatchCount)
{
return (videos, syncCount);
}
}
else
{
if (string.IsNullOrWhiteSpace(_currentTaskItemId)) continue;
//处理多个视频-组合的图文视频--类似动图。
List<DouyinMergeVideoDto> dynamicVideoUrls = new List<DouyinMergeVideoDto>();
// 当需要下载动态视频时,获取其他URL
if (config.DownDynamicVideo && item.Images != null && item.Images.Count > 0)
{
foreach (var img in item.Images)
{
if (img.DynamicVideo?.BitRate?.Count > 0)
{
foreach (var btv in img.DynamicVideo.BitRate)
{
var targetUrl = btv.PlayAddr?.UrlList?.FirstOrDefault(x => x.StartsWith("https://www.douyin.com/aweme/v1/play"));
if (targetUrl != null)
{
var height = btv.PlayAddr?.Height ?? 1920;
var width = btv.PlayAddr?.Width ?? 1080;
DouyinMergeVideoDto info = new DouyinMergeVideoDto
{
Path = targetUrl,
Height = height,
Width = width
};
dynamicVideoUrls.Add(info);
}
}
}
}
}
// 处理核心逻辑
if (dynamicVideoUrls.Count > 0)
{
// 处理动态视频
var dynamicVideo = await ProcessDynamicVideo(dynamicVideoUrls, cookie, item, config, followed, cate);
if (dynamicVideo != null)
{
try
{
var remoteReady = dynamicVideo.StorageType == StorageType.Local;
if (!string.IsNullOrEmpty(dynamicVideo.DynamicVideos))
{
var dynamicVideos = JsonConvert.DeserializeObject<List<DouyinMergeVideoDto>>(dynamicVideo.DynamicVideos);
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]-动态视频[{item.Desc}],下载成功 ,共{dynamicVideos?.Count}个视频...");
if (dynamicVideos != null && dynamicVideos.Count > 0)
{
var savePath = DouyinFileNameHelper.RemoveNumberSuffix(dynamicVideo.VideoSavePath);
//音频文件下载地址
var mp3Url = item?.Music?.PlayUrl?.UrlList?.FirstOrDefault();
var (mp4Path, mp3Path) = await douyinMergeVideoService.MergeMultipleVideosAsync(dynamicVideos, mp3Url, savePath, cookie.Cookies);
if (!string.IsNullOrWhiteSpace(mp4Path) && File.Exists(mp4Path))
{
if (dynamicVideo.StorageType.IsRemote())
{
dynamicVideo.PendingStoragePath = await UploadLocalFileAsync(mp4Path, dynamicVideo.PendingStoragePath);
dynamicVideo.FileSize = new FileInfo(mp4Path).Length;
dynamicVideo.VideoSavePath = dynamicVideo.PendingStoragePath;
if (config.KeepDynamicVideo)
{
var remoteParts = new List<DouyinMergeVideoDto>();
foreach (var part in dynamicVideos.Where(x => File.Exists(x.Path)))
{
var remotePart = dynamicVideo.PendingStoragePath.Replace(".mp4", $"-{Path.GetFileName(part.Path)}");
remotePart = await UploadLocalFileAsync(part.Path, remotePart);
remoteParts.Add(new DouyinMergeVideoDto { Path = remotePart, Height = part.Height, Width = part.Width });
}
dynamicVideo.DynamicVideos = JsonConvert.SerializeObject(remoteParts);
}
else
{
dynamicVideo.DynamicVideos = null;
}
remoteReady = true;
}
else
{
dynamicVideo.VideoSavePath = mp4Path;
}
if (!config.KeepDynamicVideo)
{
if (File.Exists(mp3Path))
{
File.Delete(mp3Path);
}
//不保留原视频-删除
foreach (var opath in dynamicVideos)
{
if (File.Exists(opath.Path))
File.Delete(opath.Path);
}
}
}
}
}
if (!remoteReady)
{
if (!string.IsNullOrWhiteSpace(dynamicVideo.VideoCoverSavePath))
await mediaStorageRouter.Resolve(dynamicVideo.StorageType).DeleteAsync(dynamicVideo.VideoCoverSavePath);
Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}]-动态视频[{item.Desc}]合并或上传失败,不保存无效远端记录");
continue;
}
//动态视频生成nfo
if (dynamicVideo.StorageType == StorageType.Local && cate != null && (VideoType == VideoTypeEnum.dy_mix || VideoType == VideoTypeEnum.dy_series))
{
NfoFileGenerator.GenerateVideoNfoFile(config.CloseNfo,dynamicVideo, cate.Name);
}
else if (dynamicVideo.StorageType == StorageType.Local)
{
NfoFileGenerator.GenerateVideoNfoFile(config.CloseNfo, dynamicVideo);
}
await WriteRemoteNfoAsync(config, dynamicVideo, cate);
if (replaceOldStorage) dynamicVideo.SupersededStorageSnapshot = exitVideo;
videos.Add(dynamicVideo);
syncCount++;
if (syncCount + syncCount1 >= config.BatchCount)
{
return (videos, syncCount);
}
}
finally
{
CleanupTemporaryDirectory(dynamicVideo.TemporaryDirectory);
}
}
else
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]-动态视频[{item.Desc}],下载失败...");
if (!string.IsNullOrWhiteSpace(_currentTaskItemId))
await videoTaskService.MarkFailedAsync(_currentTaskItemId,
new MediaSourceException(MediaDownloadFailureKind.SourceUnavailable, "动态视频下载或合成失败"),
VideoTaskErrorType.SourceUnavailable);
}
}
else
{
var hasImages = item?.Images?.Any(image =>
image?.UrlList?.Any(url => !string.IsNullOrWhiteSpace(url)) == true ||
image?.DynamicVideo?.BitRate?.Any(rate =>
rate?.PlayAddr?.UrlList?.Any(url => !string.IsNullOrWhiteSpace(url)) == true) == true) == true;
if (!hasImages)
{
await videoTaskService.MarkSkippedAsync(_taskId, trackedAwemeId, cookie.Id, cookie.UserName,
VideoType, item?.Desc, item?.Author?.Nickname, VideoTaskSkipReason.NoMediaSource,
"作品没有可用的视频、图文或动态媒体源,已跳过并继续同步。");
}
// 处理图文视频逻辑
else if (config.DownImageVideo || config.DownMp3 || config.DownImage)
{
var mergevideo = await ProcessImageSetAndMergeToVideo(cookie, item, config, followed, cate);
if (mergevideo != null)
{
if (replaceOldStorage) mergevideo.SupersededStorageSnapshot = exitVideo;
videos.Add(mergevideo);
syncCount++;
if (syncCount + syncCount1 >= config.BatchCount)
{
return (videos, syncCount);
}
}
else
{
await videoTaskService.MarkFailedAsync(_currentTaskItemId,
new IOException("图文媒体下载或合成失败,请重新执行对应的同步任务重试。"),
VideoTaskErrorType.SourceUnavailable);
}
}
else
{
await videoTaskService.MarkSkippedAsync(_taskId, trackedAwemeId, cookie.Id, cookie.UserName,
VideoType, item?.Desc, item?.Author?.Nickname, VideoTaskSkipReason.ConfigurationExcluded,
"作品属于图文或动态内容,但当前配置未启用对应下载方式。");
}
}
}
}
catch (Exception ex)
{
try
{
if (string.IsNullOrWhiteSpace(_currentTaskItemId))
{
var failedItem = await videoTaskService.AddItemAsync(_taskId, trackedAwemeId, cookie.Id,
cookie.UserName, VideoType, item?.Desc, item?.Author?.Nickname);
_currentTaskItemId = failedItem.Id;
}
await videoTaskService.MarkFailedAsync(_currentTaskItemId, ex);
}
catch (Exception trackingError)
{
Log.Error(trackingError, "[{Cookie}][{Type}]记录异常作品失败:{AwemeId}",
cookie.UserName, VideoType.GetDesc(), trackedAwemeId);
}
Log.Error(ex, "[{Cookie}][{Type}]作品处理失败,已跳过并继续:{AwemeId}",
cookie.UserName, VideoType.GetDesc(), trackedAwemeId);
}
}
return (videos, syncCount);
}
private async Task<bool> AutoDistinct(AppConfig config, DouyinVideo exitVideo, DouyinCookie cookie)
{
// 去重,检查视频是否已存在(按优先级下载)
if (config.AutoDistinct)
{
if (exitVideo != null)
{
// 2. 已存在视频:先判断本地文件是否存在
if (await mediaStorageRouter.Resolve(exitVideo.StorageType).ExistsAsync(exitVideo.VideoSavePath))
{
List<PriorityLevelDto> priLevs = new List<PriorityLevelDto>();
if (!string.IsNullOrWhiteSpace(config.PriorityLevel))
{
priLevs = JsonConvert.DeserializeObject<List<PriorityLevelDto>>(config.PriorityLevel);
}
// 4. 处理优先级:获取「最高优先级」(Sort 越小优先级越高)
PriorityLevelDto maxPriority = null;
if (priLevs.Any())
{
// 前端已配置优先级:取 Sort 最小的(1最高)
maxPriority = priLevs.OrderBy(x => x.Sort).FirstOrDefault();
}
else
{
// 前端未配置:使用默认优先级(喜欢 > 收藏 > 关注)
maxPriority = new PriorityLevelDto { Id = 1, Sort = 1, Name = "喜欢的" }; // 默认「喜欢的视频」最高
}
// 5. 转换为当前上下文的视频类型
var maxPriorityType = (VideoTypeEnum)maxPriority.Id; // 配置的最高优先级类型
// 6. 获取已存在视频的类型(从数据库中 exitVideo 读取,需确保字段存在)
var exitVideoType = exitVideo.ViedoType; // 假设数据库存储了 VideoType.GetVideoTypeDesc()1/2/3
// 7. 优先级逻辑判断(核心)
if (VideoType == maxPriorityType)
{
// 情况1:当前要下载的是「最高优先级」视频
if (exitVideoType == VideoType)
{
// 已存在同优先级视频 → 跳过下载(避免重复)
//Log.Debug($"[{VideoType.GetVideoTypeDesc()}]-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(同最高优先级),跳过");
return false;
}
else
{
// 已存在「低优先级」视频 → 替换(删除旧文件,继续下载新的最高优先级视频)
//Log.Debug($"[{VideoType.GetVideoTypeDesc()}]-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(低优先级:{exitVideoType.GetVideoTypeDesc()}),替换为最高优先级:{currentVideoType.GetVideoTypeDesc()}");
// 删除旧的低优先级文件(可选:也可保留备份,根据需求调整)
try
{
//File.Delete(exitVideo.VideoSavePath);
await DeleteOldVideoAsync(exitVideo);
//Log.Debug($"已删除旧文件:{exitVideo.VideoSavePath}");
}
catch (Exception ex)
{
Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}]-删除重复的文件[{exitVideo.VideoTitle}]失败:{ex.Message}", ex);
// 即使删除失败,仍继续下载(新文件会覆盖旧文件,或按路径规则重命名)
}
// 继续执行下载逻辑(覆盖旧数据)
}
}
else
{
// 情况2:当前要下载的是「非最高优先级」视频
if (exitVideoType == maxPriorityType)
{
// 已存在「最高优先级」视频 → 跳过(不替换最高优先级)
//Log.Debug($"[{VideoType.GetVideoTypeDesc()}]-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在最高优先级视频({maxPriorityType}),当前类型({currentVideoType.GetVideoTypeDesc()})优先级低,跳过");
return false;
}
else
{
// 已存在「其他非最高优先级」视频 → 比较两者优先级
// 获取当前类型和已存在类型的 Sort 值
var currentSort = priLevs.FirstOrDefault(x => x.Id == (int)VideoType)?.Sort ?? int.MaxValue;
var exitSort = priLevs.FirstOrDefault(x => x.Id == (int)exitVideoType)?.Sort ?? int.MaxValue;
if (currentSort < exitSort)
{
// 当前类型优先级更高 → 替换旧视频
//Log.Debug($"[{VideoType.GetVideoTypeDesc()}]-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在低优先级视频({exitVideoType.GetVideoTypeDesc()}),替换为当前优先级:{currentVideoType.GetVideoTypeDesc()}");
// 删除旧文件
await DeleteOldVideoAsync(exitVideo);
// 继续下载
}
else
{
// 当前类型优先级更低或相等 → 跳过
//Log.Debug($"[{VideoType.GetVideoTypeDesc()}]-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在更高/同等优先级视频({exitVideoType.GetVideoTypeDesc()}),当前类型({currentVideoType.GetVideoTypeDesc()})跳过");
return false;
}
}
}
}
else
{
if (exitVideo.OnlyImgOrOnlyMp3)
{
return true;//说明是图文视频,不需要再下载视频了
}
else
{
//记录存在,但本地文件不存在,则继续下载。
//Log.Debug($"[{VideoType.GetVideoTypeDesc()}]-视频-{exitVideo.AwemeId}记录存在,但本地文件缺失,删除记录,重新下载");
//删除原来的记录
await douyinVideoService.DeleteById(exitVideo.Id);
}
}
}
}
return true;
}
private async Task DeleteOldVideoAsync(DouyinVideo exitVideo)
{
if (exitVideo.StorageType.IsRemote())
{
await StorageArtifactCleaner.DeleteWebDavArtifactsAsync(
mediaStorageRouter.Resolve(exitVideo.StorageType), exitVideo);
return;
}
if (File.Exists(exitVideo.VideoSavePath))
{
var dirPath = Path.GetDirectoryName(exitVideo.VideoSavePath);
if (Directory.Exists(dirPath))
{
Directory.Delete(dirPath, true);
//Log.Debug($"[{VideoType.GetVideoTypeDesc()}]-已删除旧文件夹:{dirPath}");
}
//查看是否还有其他文件,如果没有则删除文件夹
var parentDir = Path.GetDirectoryName(exitVideo.VideoSavePath);
if (Directory.Exists(parentDir) && !Directory.EnumerateFileSystemEntries(parentDir).Any())
{
Directory.Delete(parentDir);
//Log.Debug($"[{VideoType.GetVideoTypeDesc()}]-已删除空文件夹:{parentDir}");
}
}
}
/// <summary>
/// 处理单个视频--正常的收藏喜欢关注的 视频
/// 负责下载视频、封面、头像,生成NFO文件,创建视频实体等
/// </summary>
/// <param name="cookie">用户Cookie</param>
/// <param name="item">视频信息</param>
/// <param name="config">应用配置</param>
/// <param name="followed">关注</param>
/// <param name="cate"></param>
/// <returns>处理后的视频实体,如果处理失败则为null</returns>
protected async Task<DouyinVideo> ProcessSingleVideo(
DouyinCookie cookie,
Aweme item,
AppConfig config,
DouyinFollowed followed = null,
DouyinCollectCate cate = null,
DouyinVideo supersededStorageSnapshot = null)
{
// 检查视频数据是否有效
if (!IsAwemeValid(item)) return null;
// 获取视频最佳下载地址
var v = GetBestMatchedVideoUrl(item, config);
if (v == null)
{
Serilog.Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}][{item.Desc}]未获取到下载地址");
return null;
}
//Serilog.Log.Debug($"{v.QualityType}-{v.BitRateValue}-{v.IsH265}-{v.HdrBit}");
var candidates = GetMediaCandidates(item, v);
var videoUrl = candidates.FirstOrDefault();
if (string.IsNullOrWhiteSpace(videoUrl))
{
Serilog.Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}]未获取到下载地址");
return null;
}
// 创建保存文件夹
var saveFolder = CreateSaveFolder(cookie, item, config, followed, cate);
// 获取视频文件名
var fileName = GetVideoFileName(cookie, item, config, cate);
// 拼接视频保存路径
var savePath = CombineStoragePath(saveFolder, fileName);
var retrySnapshot = BuildRetrySnapshot(cookie, item, v, savePath, cate, supersededStorageSnapshot);
await videoTaskService.UpdateItemPlanAsync(_currentTaskItemId, savePath, retrySnapshot, v.PlayAddr?.DataSize ?? 0);
// 如果文件已存在,跳过
if (await mediaStorageRouter.Resolve(ActiveStorageType).ExistsAsync(savePath))
{
if (supersededStorageSnapshot != null)
{
var length = await mediaStorageRouter.Resolve(ActiveStorageType).GetLengthAsync(savePath) ?? 0;
if (length <= 0)
throw new IOException($"{ActiveStorageType} 目标文件存在但长度无效:{savePath}");
var adopted = JsonConvert.DeserializeObject<DouyinVideo>(
JsonConvert.SerializeObject(supersededStorageSnapshot));
adopted.StorageType = ActiveStorageType;
adopted.VideoSavePath = savePath;
adopted.FileSize = length;
adopted.SyncTime = DateTime.Now;
adopted.TaskItemId = _currentTaskItemId;
adopted.SupersededStorageSnapshot = supersededStorageSnapshot.StorageType == StorageType.Local
? supersededStorageSnapshot : null;
await videoTaskService.UpdateItemPlanAsync(_currentTaskItemId, savePath, adopted, length);
await videoTaskService.SetItemStageAsync(_currentTaskItemId, VideoTaskItemStage.Committing, savePath);
return adopted;
}
await videoTaskService.MarkSkippedAsync(_taskId, item.AwemeId, cookie.Id, cookie.UserName, VideoType,
item.Desc, item.Author?.Nickname, VideoTaskSkipReason.AlreadyExists, "目标文件已存在。");
_currentTaskItemId = null;
return null;
}
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}][{item?.Author?.Nickname ?? ""}]-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]开始下载...");
// 随机延迟
await Task.Delay(_random.Next(1, 4) * 1000);
// 下载视频
await videoTaskService.SetItemStageAsync(_currentTaskItemId, VideoTaskItemStage.Downloading, savePath);
var download = await douyinHttpClientService.DownloadToStorageAsync(
ActiveStorageType,
videoUrl,
savePath,
cookie.Cookies,
candidates.Skip(1).ToList(),
maxRetryCount: Math.Min(12, candidates.Count));
if (!download.Success)
{
var disposition = await videoTaskService.MarkDownloadFailedAsync(_currentTaskItemId, cookie.Id, download);
_stopCurrentCookie = disposition is SourceFailureDisposition.WaitForSource or SourceFailureDisposition.RequiresAuthorization;
Log.Warning("[{Cookie}][{Type}]媒体下载失败:Host={Host}, Status={Status}, Kind={Kind}, Candidates={Candidates}",
cookie.UserName, VideoType.GetDesc(), download.SourceHost, download.HttpStatusCode,
download.FailureKind, download.AttemptedUrlCount);
_currentTaskItemId = null;
return null;
}
savePath = download.ActualSavePath;
await videoTaskService.RecordSourceSuccessAsync(cookie.Id);
if (ActiveStorageType == StorageType.Local)
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}][{item?.Author?.Nickname ?? ""}]-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]下载完成.");
}
await videoTaskService.SetItemStageAsync(_currentTaskItemId, VideoTaskItemStage.Verifying, savePath);
var actualLength = await mediaStorageRouter.Resolve(ActiveStorageType).GetLengthAsync(savePath);
if (!actualLength.HasValue || actualLength <= 0)
{
var error = new IOException("下载完成后没有找到有效的主媒体文件");
await videoTaskService.MarkFailedAsync(_currentTaskItemId, error, VideoTaskErrorType.IntegrityCheckFailed);
return null;
}
// 下载视频封面
var coverSavePath = await DownVideoCover(item, savePath, cookie, cate,config);
// 下载作者头像
var avatarSavePath = await DownAuthorAvatar(cookie, item,config);
// 创建视频实体
var entity = await CreateVideoEntity(config, cookie, item, v, savePath, coverSavePath, avatarSavePath, null, cate);
if (supersededStorageSnapshot != null)
{
entity.Id = supersededStorageSnapshot.Id;
entity.SupersededStorageSnapshot = supersededStorageSnapshot;
}
entity.FileSize = actualLength.Value;
entity.TaskItemId = _currentTaskItemId;
await videoTaskService.UpdateItemPlanAsync(_currentTaskItemId, savePath, entity, actualLength.Value);
await videoTaskService.SetItemStageAsync(_currentTaskItemId, VideoTaskItemStage.Committing, savePath);
return entity;
}
private DouyinVideo BuildRetrySnapshot(
DouyinCookie cookie,
Aweme item,
VideoBitRate bitRate,
string savePath,
DouyinCollectCate cate,
DouyinVideo supersededStorageSnapshot)
{
return new DouyinVideo
{
Id = supersededStorageSnapshot?.Id ?? IdGener.GetLong().ToString(),
ViedoType = VideoType,
StorageType = ActiveStorageType,
AwemeId = item.AwemeId,
CookieId = cookie.Id,
Author = item.Author?.Nickname,
AuthorId = item.Author?.Uid,
DyUserId = item.AuthorUserId == 0 ? item.Author?.Uid : item.AuthorUserId.ToString(),
VideoTitle = string.IsNullOrWhiteSpace(item.Desc) ? $"{item.Author?.Nickname}-{item.CreateTime}" : item.Desc,
VideoUrl = bitRate.PlayAddr?.UrlList?.FirstOrDefault(),
VideoSavePath = savePath,
FileSize = bitRate.PlayAddr?.DataSize ?? 0,
FileHash = bitRate.PlayAddr?.FileHash,
Resolution = $"{bitRate.PlayAddr?.Width}×{bitRate.PlayAddr?.Height}",
CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
SyncTime = DateTime.Now,
CateId = cate?.Id,
CateXId = cate?.XId,
SupersededStorageSnapshot = supersededStorageSnapshot
};
}
internal static VideoBitRate GetBestMatchedVideoUrl(Aweme item, AppConfig config)
{
var bitRates = item?.Video?.BitRate?
.Where(rate => rate?.PlayAddr?.UrlList?.Any(url => !string.IsNullOrWhiteSpace(url)) == true)
.ToList() ?? new List<VideoBitRate>();
if (bitRates.Count == 0) return null;
VideoBitRate v;
if (config?.VideoEncoder.HasValue == true && config.VideoEncoder.Value == 265)
{
v = bitRates.Where(v => v.IsH265 == 1)
.OrderByDescending(v => v.BitRateValue)
.FirstOrDefault();
v ??= bitRates.Where(v => v.IsH265 == 0)
.OrderByDescending(v => v.BitRateValue)
.FirstOrDefault();
}
else
{
v = bitRates.Where(v => v.IsH265 == 0)
.OrderByDescending(v => v.BitRateValue)
.FirstOrDefault();
}
return v;
}
/// <summary>
/// 动态视频处理
/// </summary>
/// <param name="dynamicUrls"></param>
/// <param name="cookie"></param>
/// <param name="item"></param>
/// <param name="config"></param>
/// <param name="followed"></param>
/// <param name="cate"></param>
/// <returns></returns>
protected async Task<DouyinVideo> ProcessDynamicVideo(List<DouyinMergeVideoDto> dynamicUrls, DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed = null, DouyinCollectCate cate = null)
{
string temporaryDirectory = null;
var handedOffForMerge = false;
try
{
// 创建保存文件夹
var targetFolder = CreateSaveFolder(cookie, item, config, followed, cate);
// 获取视频文件名
//var fileName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId) + ".mp4";
var fileName = GetVideoFileName(cookie, item, config, cate);
// 拼接视频保存路径
var targetSavePath = CombineStoragePath(targetFolder, fileName);
temporaryDirectory = ActiveStorageType.IsRemote() ? CreateTemporaryDirectory() : null;
var saveFolder = temporaryDirectory ?? targetFolder;
var savePath = Path.Combine(saveFolder, fileName);
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]-动态视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]开始下载...");
// 随机延迟
int i = 1;
List<DouyinMergeVideoDto> dynamicSavePaths = new List<DouyinMergeVideoDto>();
MediaDownloadResult lastDownloadFailure = null;
var allSourceForbidden = true;
var failedParts = 0;
foreach (var dynamicUrl in dynamicUrls)
{
// 下载动态视频
var dynamicSavePath = savePath.Replace(".mp4", $"_00{i}.mp4");
DouyinMergeVideoDto v = new DouyinMergeVideoDto() { Height = dynamicUrl.Height, Width = dynamicUrl.Width, Path = dynamicSavePath };
i++;
// 如果文件已存在,跳过
if (File.Exists(dynamicSavePath))
{
//Log.Debug($"[{VideoType.GetVideoTypeDesc()}]-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]已存在,跳过下载.");
dynamicSavePaths.Add(v);
continue;
}
var download = await douyinHttpClientService.DownloadAsync(dynamicUrl.Path, dynamicSavePath, cookie.Cookies);
if (!download.Success)
{
failedParts++;
lastDownloadFailure = download;
allSourceForbidden &= download.FailureKind == MediaDownloadFailureKind.SourceForbidden;
Log.Warning("[{Cookie}][{Type}]动态视频分片下载失败:Host={Host}, Status={Status}, Kind={Kind}",
cookie.UserName, VideoType.GetDesc(), download.SourceHost, download.HttpStatusCode, download.FailureKind);
}
else
{
v.Path = download.ActualSavePath;
dynamicSavePaths.Add(v);
await videoTaskService.RecordSourceSuccessAsync(cookie.Id);
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}][{item?.Author?.Nickname ?? ""}]-动态视频[{dynamicSavePath}]-00{i},下载完成.");
}
await Task.Delay(_random.Next(2, 10) * 1000);
}
if (dynamicSavePaths.Count == 0)
{
Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}]-动态视频[{item.Desc}]没有下载到可用分片");
if (lastDownloadFailure != null && !string.IsNullOrWhiteSpace(_currentTaskItemId))
{
if (!allSourceForbidden && lastDownloadFailure.FailureKind == MediaDownloadFailureKind.SourceForbidden)
lastDownloadFailure = new MediaDownloadResult
{
Success = false,
ActualSavePath = lastDownloadFailure.ActualSavePath,
FailureKind = MediaDownloadFailureKind.SourceUnavailable,
SourceHost = lastDownloadFailure.SourceHost,
AttemptedUrlCount = failedParts,
Message = "动态视频分片返回了多种来源错误。"
};
var disposition = await videoTaskService.MarkDownloadFailedAsync(_currentTaskItemId, cookie.Id, lastDownloadFailure);
_stopCurrentCookie = disposition is SourceFailureDisposition.WaitForSource or SourceFailureDisposition.RequiresAuthorization;
_currentTaskItemId = null;
}
return null;
}
// 下载视频封面
var coverSavePath = await DownVideoCover(item, ActiveStorageType.IsRemote() ? targetSavePath : savePath, cookie, cate,config);
// 下载作者头像
var avatarSavePath = await DownAuthorAvatar(cookie, item, config);
// 创建视频实体
var virtualBitRate = new VideoBitRate
{
PlayAddr = new PlayAddr
{
Width = item?.Images?.FirstOrDefault()?.Width ?? 0,
Height = item?.Images?.FirstOrDefault()?.Height ?? 0,
DataSize = DouyinFileUtils.GetTotalFileSize(dynamicSavePaths.Select(x => x.Path).ToList()) // 合成视频的文件大小
}
};
var entity = await CreateVideoEntity(config, cookie, item, virtualBitRate, dynamicSavePaths.FirstOrDefault()?.Path, coverSavePath, avatarSavePath, dynamicSavePaths, cate);
entity.PendingStoragePath = targetSavePath;
entity.TemporaryDirectory = temporaryDirectory;
handedOffForMerge = true;
return entity;
}
finally
{
if (!handedOffForMerge) CleanupTemporaryDirectory(temporaryDirectory);
}
}
internal static List<string> GetMediaCandidates(Aweme item, VideoBitRate selected)
{
var result = new List<string>();
void Add(VideoBitRate bitRate)
{
foreach (var url in bitRate?.PlayAddr?.UrlList ?? new List<string>())
if (!string.IsNullOrWhiteSpace(url) && !result.Contains(url, StringComparer.Ordinal)) result.Add(url);
}
Add(selected);
foreach (var bitRate in item?.Video?.BitRate ?? new List<VideoBitRate>()) Add(bitRate);
return result.Take(12).ToList();
}
/// <summary>
/// 负责下载图片、合成视频、处理封面和头像等
/// </summary>
/// <param name="cookie">用户Cookie</param>
/// <param name="item">视频信息(包含图片集)</param>
/// <param name="config">应用配置</param>
/// <param name="followed">应用配置</param>
/// <param name="cate"></param>
/// <returns>合成后的视频实体,如果处理失败则为null</returns>
protected async Task<DouyinVideo> ProcessImageSetAndMergeToVideo(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
string temporaryDirectory = null;
try
{
// 提取图片URL列表
List<DouyinMergeVideoDto> imageUrls = item?.Images?
.Where(img => img?.UrlList != null && img.UrlList.Any())
.Select(img => new DouyinMergeVideoDto { Path = img.UrlList.FirstOrDefault(), Height = img.Height, Width = img.Width })
.Where(img => !string.IsNullOrWhiteSpace(img.Path))
.ToList();
// 如果没有图片,保留明确失败原因供任务中心展示。
if (imageUrls == null || !imageUrls.Any())
{
throw new InvalidOperationException("图文作品没有可用的静态图片地址");
}
// 创建图片保存文件夹
var targetFolder = CreateSaveFolder(cookie, item, config, followed, cate);
temporaryDirectory = ActiveStorageType.IsRemote() ? CreateTemporaryDirectory() : null;
var fileNamefolder = temporaryDirectory ?? targetFolder;
if (!Directory.Exists(fileNamefolder)) Directory.CreateDirectory(fileNamefolder);
var fileName = GetVideoFileName(cookie, item, config, cate);
// 合成视频的保存路径
var savePath = Path.Combine(fileNamefolder, fileName);
var targetSavePath = CombineStoragePath(targetFolder, fileName);
// 如果文件已存在,返回null
if (File.Exists(savePath))
{
FileInfo fileInfo = new FileInfo(savePath);
if (fileInfo.Length > 0)
return null;
}
// 获取音乐URL
var mp3Url = item.Music?.PlayUrl?.UrlList?.FirstOrDefault();
var firstImage = item.Images.FirstOrDefault(img => img != null);
int height = firstImage?.Height ?? 0;
int width = firstImage?.Width ?? 0;
// 准备合成视频的请求参数
var reqParams = new MediaMergeRequest
{
ImageDurationPerSecond = 3, // 每张图片显示的时长(秒)
OutputFormat = "mp4", // 输出视频格式
VideoFps = 30, // 视频帧率
AudioUrls = string.IsNullOrWhiteSpace(mp3Url) ? new List<string>() : new List<string> { mp3Url }, // 音频URL列表
ImageUrls = imageUrls, // 图片URL列表
VideoWidth = width > 0 ? width : 1080, // 视频宽度
VideoHeight = height > 0 ? height : 1920, // 视频高度
};
// 执行图片合成视频操作
string mergeError = null;
var mergeResult = await douyinMergeVideoService.MergeToVideo(
cookie.Cookies,
AppContext.BaseDirectory,
reqParams,
savePath,
fileNamefolder,
config.DownImageVideo,
config.DownImage,
config.DownMp3,
warning => _currentItemWarnings.Add(warning),
error => mergeError = error);
if (!mergeResult)
{
Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}]-图文视频-[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]合成失败!!!");
throw new InvalidOperationException(
$"图文媒体下载或合成失败:{mergeError ?? "未生成有效媒体文件"}");
}
// 获取不带扩展名的完整路径
//string fullPathWithoutExtension = Path.Combine(Path.GetDirectoryName(savePath),Path.GetFileNameWithoutExtension(savePath) );
if (config.DownImageVideo)
{
// 检查合成后的视频文件是否有效
if (!File.Exists(savePath) || new FileInfo(savePath).Length <= 0)
{
Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}]-图文视频-[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]合成失败!!!");
// 清理无效的文件和文件夹
if (Directory.Exists(fileNamefolder))
{
File.Delete(savePath);
Directory.Delete(fileNamefolder, true);
Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}]-图文视频-删除合成失败的视频文件和目录...");
}
throw new MediaIntegrityException("图文合成完成,但输出视频不存在或为空");
}
}
var isMediaOnly = !config.DownImageVideo && (config.DownImage || config.DownMp3);
var localArtifactPath = config.DownImageVideo
? savePath
: Directory.EnumerateFiles(fileNamefolder, "*", SearchOption.AllDirectories).FirstOrDefault();
if (string.IsNullOrWhiteSpace(localArtifactPath) || !File.Exists(localArtifactPath))
{
Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}]-图文作品[{item.Desc}]未生成任何可保存的媒体文件");
throw new InvalidOperationException("图文处理完成,但未生成任何可保存的媒体文件");
}
var generatedSize = new FileInfo(localArtifactPath).Length;
var storageArtifactPath = localArtifactPath;
List<DouyinMergeVideoDto> uploadedArtifacts = null;
if (ActiveStorageType.IsRemote())
{
var uploadedPaths = await UploadDirectoryAsync(fileNamefolder, targetFolder);
uploadedArtifacts = Directory.EnumerateFiles(fileNamefolder, "*", SearchOption.AllDirectories)
.Select(file => new DouyinMergeVideoDto
{
Path = uploadedPaths[Path.GetRelativePath(fileNamefolder, file).Replace('\\', '/')]
})
.ToList();
var relativeArtifact = Path.GetRelativePath(fileNamefolder, localArtifactPath).Replace('\\', '/');
storageArtifactPath = config.DownImageVideo
? uploadedPaths.GetValueOrDefault(
Path.GetRelativePath(fileNamefolder, savePath).Replace('\\', '/'), targetSavePath)
: uploadedPaths.GetValueOrDefault(
relativeArtifact, StoragePath.CombineRemote(targetFolder, relativeArtifact));
}
var coverUrl = cate is not null && cate.CateType != VideoTypeEnum.dy_custom_collect
? (item.MixInfo?.CoverUrl?.UrlList?.FirstOrDefault() ?? imageUrls.FirstOrDefault()?.Path ?? item.Music?.CoverHd?.UrlList?.FirstOrDefault())
: imageUrls.FirstOrDefault()?.Path;
// 下载作者头像
var avatarSavePath = await DownAuthorAvatar(cookie, item, config);
// 为合成的视频创建一个“虚拟”的BitRate对象,以便复用CreateVideoEntity方法
var virtualBitRate = new VideoBitRate
{
PlayAddr = new PlayAddr
{
Width = reqParams.VideoWidth,
Height = reqParams.VideoHeight,
DataSize = generatedSize
}
};
// 下载视频封面(使用第一张图片作为封面)
var coverReferencePath = storageArtifactPath;
string coverSavePath = await DownVideoCover(coverUrl, coverReferencePath, cookie, config);
// 创建视频实体
var videoEntity = await CreateVideoEntity(config,
cookie, item, virtualBitRate, storageArtifactPath, coverSavePath, avatarSavePath, null, cate, isMediaOnly);
// 特殊处理合成视频的字段
videoEntity.FileHash = string.Empty; // 合成视频没有原始文件哈希
videoEntity.VideoUrl = "/"; // 合成视频没有原始URL
videoEntity.ViedoType = VideoType;
videoEntity.IsMergeVideo = 1;// 标记为图片合成视频
if (uploadedArtifacts?.Count > 0)
videoEntity.DynamicVideos = JsonConvert.SerializeObject(uploadedArtifacts);
return videoEntity;
}
catch (Exception ex)
{
Log.Error(ex, $"[{cookie.UserName}][{VideoType.GetDesc()}]-图片视频同步-处理图片集并合成视频时出错");
if (ActiveStorageType.IsRemote() && ex is IOException
&& ex is not (MediaStorageException or MediaIntegrityException))
throw new MediaStorageException($"图文媒体写入 {ActiveStorageType} 失败", ex);
throw;
}
finally
{
CleanupTemporaryDirectory(temporaryDirectory);
}
}
/// <summary>
/// 保存视频信息到数据库
/// 批量插入视频实体列表到数据库中
/// </summary>
/// <param name="cookie"></param>
/// <param name="videos">要保存的视频实体列表</param>
/// <returns>保存成功的视频数量</returns>
protected async Task<int> SaveVideos(DouyinCookie cookie, List<DouyinVideo> videos)
{
if (!videos.Any()) return 0;
try
{
var saved = await douyinVideoService.BatchInsertOrUpdate(videos);
if (!saved)
{
Log.Error($"[{cookie.UserName}][{VideoType.GetDesc()}]-批量保存视频到数据库失败,开始清理已写入媒体");
foreach (var video in videos.Where(x => !string.IsNullOrWhiteSpace(x.TaskItemId)))
await videoTaskService.MarkFailedAsync(video.TaskItemId,
new IOException("数据库提交失败"), VideoTaskErrorType.DatabaseCommitFailed);
await CleanupFailedVideos(cookie, videos);
return 0;
}
foreach (var video in videos.Where(x => !string.IsNullOrWhiteSpace(x.TaskItemId)))
{
var length = await mediaStorageRouter.Resolve(video.StorageType).GetLengthAsync(video.VideoSavePath) ?? video.FileSize;
var warning = video.TaskWarning;
var cleanupPending = false;
string cleanupError = null;
if (video.SupersededStorageSnapshot != null)
{
try
{
await douyinVideoService.CleanupSupersededLocalArtifactsAsync(
video.SupersededStorageSnapshot, video.VideoSavePath);
}
catch (Exception cleanupException)
{
Log.Warning(cleanupException, "新存储记录已提交,但旧本地文件清理失败:{AwemeId}", video.AwemeId);
cleanupPending = true;
cleanupError = cleanupException.GetBaseException().Message;
warning = string.IsNullOrWhiteSpace(warning)
? $"已切换到 {video.StorageType},但旧本地文件清理失败,可稍后重试清理。"
: warning + $";已切换到 {video.StorageType},但旧本地文件清理失败,可稍后重试清理。";
}
}
await videoTaskService.MarkSucceededAsync(video.TaskItemId, length, video.Id, warning,
cleanupPending, cleanupError);
}
return videos.Count;
}
catch (Exception ex)
{
Log.Error(ex, $"[{cookie.UserName}][{VideoType.GetDesc()}]-批量保存视频到数据库失败");
foreach (var video in videos.Where(x => !string.IsNullOrWhiteSpace(x.TaskItemId)))
await videoTaskService.MarkFailedAsync(video.TaskItemId, ex, VideoTaskErrorType.DatabaseCommitFailed);
// 清理保存失败的视频文件
await CleanupFailedVideos(cookie, videos);
return 0;
}
}
/// <summary>
/// 下载视频封面
/// 从视频信息中提取封面URL并下载到指定文件夹
/// </summary>
/// <param name="item">视频信息</param>
/// <param name="savePath">视频保存路径</param>
/// <param name="cookie">用户Cookie</param>
/// <param name="cate"></param>
/// <returns>一个表示异步操作的任务</returns>
protected async Task<string> DownVideoCover(Aweme item, string savePath, DouyinCookie cookie, DouyinCollectCate cate,AppConfig config)
{
if (config.CloseNfo) return string.Empty;
// 定义封面URL变量
string coverUrl;
// 按照优先级获取封面URL
if (cate is not null)
{
// cate不为空时:优先MixInfo封面 → 其次Music高清封面 → 最后Video封面
coverUrl = item.MixInfo?.CoverUrl?.UrlList?.FirstOrDefault()
?? item.Video?.Cover?.UrlList?.LastOrDefault()
?? item.Music?.CoverHd?.UrlList?.FirstOrDefault();
}
else
{
// cate为空时:只取Video封面
coverUrl = item.Video?.Cover?.UrlList?.FirstOrDefault();
if (string.IsNullOrWhiteSpace(coverUrl))
{
coverUrl = item.Images?.FirstOrDefault()?.DynamicVideo?.Cover?.UrlList?.FirstOrDefault();
}
}
// 调用下载封面的方法
return await DownVideoCover(coverUrl, savePath, cookie, config);
}
/// <summary>
/// 下载作者头像
/// 从视频信息中提取作者头像URL并下载到指定文件夹
/// </summary>
/// <param name="cookie">用户Cookie</param>
/// <param name="item">视频信息</param>
/// <returns>一个元组,包含头像保存路径和头像URL</returns>
protected async Task<string> DownAuthorAvatar(DouyinCookie cookie, Aweme item,AppConfig config)
{
if (config.CloseNfo) return string.Empty;
if (item.Author == null) return string.Empty;
// 优先获取高清头像
var avatarUrl = item.Author.AvatarLarger?.UrlList?.FirstOrDefault() ?? item.Author.AvatarThumb?.UrlList?.FirstOrDefault();
if (string.IsNullOrWhiteSpace(avatarUrl)) return string.Empty;
// 拼接头像保存路径
var avatarSavePath = CombineStoragePath(GetAuthorAvatarBasePath(cookie), $"{item.Author.Uid}.jpg");
var avatarDir = ActiveStorageType.IsRemote() ? StoragePath.DirectoryName(avatarSavePath) : Path.GetDirectoryName(avatarSavePath);
EnsureStorageDirectory(avatarDir);
// 如果头像文件不存在,则下载
if (!await mediaStorageRouter.Resolve(ActiveStorageType).ExistsAsync(avatarSavePath))
{
var result = await douyinHttpClientService.DownloadToStorageAsync(ActiveStorageType, avatarUrl, avatarSavePath, cookie.Cookies);
if (!result.Success)
{
_currentItemWarnings.Add($"头像下载失败({result.SourceHost ?? "未知来源"}/{result.HttpStatusCode?.ToString() ?? result.FailureKind.ToString()}");
return string.Empty;
}
avatarSavePath = result.ActualSavePath;
}
return avatarSavePath;
}
#endregion
#region 私有方法
/// <summary>
/// 检查视频数据是否有效
/// 验证视频信息是否包含必要的字段
/// </summary>
/// <param name="item">视频信息</param>
/// <returns>如果视频数据有效,则为true;否则为false</returns>
private static bool IsAwemeValid(Aweme item) => item != null && item.Video != null && item.Video.BitRate != null;
/// <summary>
/// 获取视频标签
/// 从视频信息中提取三个级别的标签
/// </summary>
/// <param name="item">视频信息</param>
/// <returns>一个元组,包含三个级别的视频标签</returns>
protected (string tag1, string tag2, string tag3) GetVideoTags(Aweme item)
{
var tags = item.VideoTags;
return (
tags?.FirstOrDefault(x => x.Level == 1)?.TagName,
tags?.FirstOrDefault(x => x.Level == 2)?.TagName,
tags?.FirstOrDefault(x => x.Level == 3)?.TagName
);
}
/// <summary>
/// 清理保存失败的视频文件
/// 当数据库保存失败时,删除已下载的视频文件和文件夹
/// </summary>
/// <param name="cookie"></param>
/// <param name="videos">保存失败的视频实体列表</param>
/// <returns>一个表示异步操作的任务</returns>
private async Task CleanupFailedVideos(DouyinCookie cookie, List<DouyinVideo> videos)
{
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]-数据库保存失败,开始清理本次下载的视频目录...");
foreach (var video in videos)
{
try
{
var storage = mediaStorageRouter.Resolve(video.StorageType);
if (video.StorageType.IsRemote())
{
await StorageArtifactCleaner.DeleteWebDavArtifactsAsync(storage, video);
}
else
{
if (!string.IsNullOrWhiteSpace(video.VideoSavePath))
await storage.DeleteAsync(video.VideoSavePath);
if (!string.IsNullOrWhiteSpace(video.VideoCoverSavePath) && video.VideoCoverSavePath != video.VideoSavePath)
await storage.DeleteAsync(video.VideoCoverSavePath);
}
if (video.StorageType == StorageType.Local && !string.IsNullOrWhiteSpace(video.VideoSavePath))
{
string directory = Path.GetDirectoryName(video.VideoSavePath);
if (!string.IsNullOrWhiteSpace(directory) && Directory.Exists(directory) && Directory.GetFileSystemEntries(directory).Length == 0)
Directory.Delete(directory);
}
Log.Debug($"[{cookie.UserName}][{VideoType.GetDesc()}]-清理失败视频文件成功: {video.VideoSavePath}!!!");
}
catch (Exception ex)
{
Log.Warning(ex, $"[{cookie.UserName}][{VideoType.GetDesc()}]-清理失败视频文件出错: {video.VideoSavePath}!!!");
}
}
}
/// <summary>
/// 下载视频封面(重载)
/// 根据指定的封面URL下载封面图片,并复制为fanart.jpg
/// </summary>
/// <param name="coverUrl">封面图片URL</param>
/// <param name="savePath">封面保存文件夹</param>
/// <param name="cookie">用户Cookie</param>
/// <returns>一个表示异步操作的任务</returns>
private async Task<string> DownVideoCover(string coverUrl, string savePath, DouyinCookie cookie,AppConfig config)
{
if (config.CloseNfo) return string.Empty;
if (string.IsNullOrWhiteSpace(coverUrl)) return string.Empty;
if (string.IsNullOrWhiteSpace(savePath)) return string.Empty;
string directoryPath = ActiveStorageType.IsRemote() ? StoragePath.DirectoryName(savePath) : Path.GetDirectoryName(savePath);
string newFileName = "poster.jpg";
if (VideoType != VideoTypeEnum.dy_mix && VideoType != VideoTypeEnum.dy_series)
{
string fileNameWithoutExt = Path.GetFileNameWithoutExtension(savePath); // 获取无后缀的原文件名,
newFileName = $"{fileNameWithoutExt}-poster.jpg"; // 拼接新文件名,
}
var coverSavePath = CombineStoragePath(directoryPath, newFileName);
// 如果封面文件不存在,则下载
if (!await mediaStorageRouter.Resolve(ActiveStorageType).ExistsAsync(coverSavePath))
{
var result = await douyinHttpClientService.DownloadToStorageAsync(ActiveStorageType, coverUrl, coverSavePath, cookie.Cookies);
if (!result.Success)
{
_currentItemWarnings.Add($"封面下载失败({result.SourceHost ?? "未知来源"}/{result.HttpStatusCode?.ToString() ?? result.FailureKind.ToString()}");
return string.Empty;
}
coverSavePath = result.ActualSavePath;
}
return coverSavePath;
}
/// <summary>
/// 创建视频实体
/// 根据视频信息、下载路径等创建DouyinVideo实体对象
/// </summary>
/// <param name="config">配置</param>
/// <param name="cookie">用户Cookie</param>
/// <param name="item">视频信息</param>
/// <param name="bitRate">视频码率信息</param>
/// <param name="savePath">视频保存路径</param>
/// <param name="coverSavePath">海报保存路径</param>
/// <param name="avatorPath"></param>
/// <param name="dynamicVideos">动态视频</param>
/// <param name="cate">短剧、合集、自定义收藏夹</param>
/// <returns>创建的视频实体对象</returns>
private async Task<DouyinVideo> CreateVideoEntity(AppConfig config,
DouyinCookie cookie, Aweme item, VideoBitRate bitRate, string savePath, string coverSavePath, string avatorPath, List<DouyinMergeVideoDto> dynamicVideos = null, DouyinCollectCate cate = null, bool onlyImgOrMp3 = false)
{
// 获取视频标签
var (tag1, tag2, tag3) = GetVideoTags(item);
var video = new DouyinVideo
{
ViedoType = VideoType,
StorageType = ActiveStorageType,
AwemeId = item.AwemeId,
Author = item.Author?.Nickname,
AuthorId = item.Author?.Uid,
AuthorAvatar = avatorPath,
AuthorAvatarUrl = item.Author?.AvatarLarger?.UrlList?.FirstOrDefault() ?? item.Author?.AvatarThumb?.UrlList?.FirstOrDefault(),
CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
VideoTitle = string.IsNullOrWhiteSpace(item.Desc) ? $"{item.Author?.Nickname}-{item.CreateTime}" : item.Desc,
//VideoTitleSimplify = VideoType == VideoTypeEnum.dy_follows? GetVideoSimplifyTitle(item):string.Empty,
Id = IdGener.GetLong().ToString(),
Resolution = $"{bitRate?.PlayAddr?.Width ?? 0}×{bitRate?.PlayAddr?.Height ?? 0}",
FileSize = bitRate?.PlayAddr?.DataSize ?? 0,
FileHash = bitRate?.PlayAddr?.FileHash,
Tag1 = tag1,
Tag2 = tag2,
Tag3 = tag3,
VideoUrl = bitRate?.PlayAddr?.UrlList?.FirstOrDefault(),
VideoCoverUrl = item.Video?.Cover?.UrlList?.FirstOrDefault(),
VideoSavePath = savePath,
VideoCoverSavePath = coverSavePath,
SyncTime = DateTime.Now,
DyUserId = item.AuthorUserId == 0 ? item.Author?.Uid : item.AuthorUserId.ToString(),
CookieId = cookie.Id,
OnlyImgOrOnlyMp3 = onlyImgOrMp3,
CateId = cate?.Id,
CateXId = cate?.XId,
TaskItemId = _currentTaskItemId,
TaskWarning = _currentItemWarnings.Count == 0 ? null : string.Join("", _currentItemWarnings.Distinct()),
};
if (cate != null && cate.CateType != VideoTypeEnum.dy_custom_collect)
{
video.VideoTitle = (string.IsNullOrWhiteSpace(item.Desc) ? cate.Name : $"[{cate.Name}]" + "_" + item.Desc)
+ "_" + (item.MixInfo?.Statis?.CurrentEpisode ?? 0);
}
if (dynamicVideos != null && dynamicVideos.Count > 0)
{
video.DynamicVideos = JsonConvert.SerializeObject(dynamicVideos);
}
else if (ActiveStorageType == StorageType.Local && !video.OnlyImgOrOnlyMp3)
{
if (cate != null && (VideoType == VideoTypeEnum.dy_mix || VideoType == VideoTypeEnum.dy_series))
{
NfoFileGenerator.GenerateVideoNfoFile(config.CloseNfo, video, cate.Name);
}
else
{
NfoFileGenerator.GenerateVideoNfoFile(config.CloseNfo, video);
}
}
if (ActiveStorageType.IsRemote() && (dynamicVideos == null || dynamicVideos.Count == 0))
await WriteRemoteNfoAsync(config, video, cate);
return video;
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
// TODO: 释放托管状态(托管对象)
}
// TODO: 释放未托管的资源(未托管的对象)并重写终结器
// TODO: 将大型字段设置为 null
disposedValue = true;
}
}
// // TODO: 仅当“Dispose(bool disposing)”拥有用于释放未托管资源的代码时才替代终结器
// ~DouyinBasicSyncJob()
// {
// // 不要更改此代码。请将清理代码放入“Dispose(bool disposing)”方法中
// Dispose(disposing: false);
// }
public void Dispose()
{
// 不要更改此代码。请将清理代码放入“Dispose(bool disposing)”方法中
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
#endregion
}
}