2103 lines
107 KiB
C#
2103 lines
107 KiB
C#
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
|
||
|
||
|
||
}
|
||
}
|