完成合集、短剧

This commit is contained in:
jianzhichu
2026-01-22 08:33:59 +08:00
parent 7175b3858a
commit e270397334
51 changed files with 3502 additions and 1411 deletions
@@ -11,12 +11,12 @@ namespace dy.net.Controllers
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class CollectController : ControllerBase
public class CateController : ControllerBase
{
private readonly DouyinCollectCateService _douyinCollectCateService;
private readonly DouyinQuartzJobService _douyinQuartzJobService;
public CollectController(DouyinCollectCateService douyinCollectCateService, DouyinQuartzJobService douyinQuartzJobService)
public CateController(DouyinCollectCateService douyinCollectCateService, DouyinQuartzJobService douyinQuartzJobService)
{
this._douyinCollectCateService = douyinCollectCateService;
_douyinQuartzJobService = douyinQuartzJobService;
+4 -4
View File
@@ -197,10 +197,10 @@ namespace dy.net.Controllers
return ApiResult.Fail($"请在飞牛应用设置里面将{dyUserCookies.UpSavePath}添加读写权限");
}
if (!string.IsNullOrWhiteSpace(dyUserCookies.ImgSavePath) && !DouyinFileUtils.HasDirectoryReadWritePermission(dyUserCookies.ImgSavePath))
{
return ApiResult.Fail($"请在飞牛应用设置里面将{dyUserCookies.ImgSavePath}添加读写权限");
}
//if (!string.IsNullOrWhiteSpace(dyUserCookies.ImgSavePath) && !DouyinFileUtils.HasDirectoryReadWritePermission(dyUserCookies.ImgSavePath))
//{
// return ApiResult.Fail($"请在飞牛应用设置里面将{dyUserCookies.ImgSavePath}添加读写权限");
//}
var checkCk = await httpClientService.CheckCookie(dyUserCookies);
+217 -39
View File
@@ -47,8 +47,7 @@ namespace dy.net.Controllers
[HttpGet("statics")]
public async Task<IActionResult> GetStaticsAsync()
{
var data = await douyinVideoService.GetStatics();
return ApiResult.Success(data);
return ApiResult.Success(await douyinVideoService.GetStatics());
}
/// <summary>
@@ -68,7 +67,7 @@ namespace dy.net.Controllers
{
return ApiResult.Fail($"视频不存在:{vid}");
}
return PlayViedo(viedo);
return await PlayVideoAsync(viedo);
}
catch (Exception ex)
{
@@ -76,11 +75,85 @@ namespace dy.net.Controllers
}
}
private IActionResult PlayViedo(DouyinVideo viedo)
{
//private IActionResult PlayViedo(DouyinVideo viedo)
//{
// 1. 拼接完整物理路径(配置路径 + 文件名)
string videoFullPath = viedo.VideoSavePath;
// // 1. 拼接完整物理路径(配置路径 + 文件名)
// string videoFullPath = viedo.VideoSavePath;
// // 2. 验证文件是否存在
// if (!System.IO.File.Exists(videoFullPath))
// {
// return ApiResult.Fail($"视频文件不存在:{videoFullPath}");
// }
// // 3. 获取文件信息(大小、类型)
// var fileInfo = new FileInfo(videoFullPath);
// long fileSize = fileInfo.Length;
// string contentType = GetContentType(videoFullPath); // 自动识别视频 MIME 类型
// // 4. 处理分片请求(前端视频标签自动发起,支持断点续传)
// if (Request.Headers.ContainsKey("Range") && long.TryParse(Request.Headers.Range.ToString().Split('=')[1].Split('-')[0], out long start))
// {
// // 分片起始位置(前端请求的起始字节)
// long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1); // 每片 2MB(可调整)
// long chunkSize = end - start + 1;
// // 5. 设置分片响应头
// Response.StatusCode = StatusCodes.Status206PartialContent;
// Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}");
// Response.Headers.Add("Accept-Ranges", "bytes");
// Response.Headers.Add("Content-Length", chunkSize.ToString());
// // 6. 读取分片并返回流
// var stream = new FileStream(
// videoFullPath,
// FileMode.Open,
// FileAccess.Read,
// FileShare.Read,
// bufferSize: 4096,
// useAsync: true);
// stream.Seek(start, SeekOrigin.Begin);
// return new FileStreamResult(stream, contentType);
// }
// else
// {
// // 完整文件请求(兼容旧浏览器)
// return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true);
// }
//}
/// <summary>
/// 抖音视频播放接口(优化版)
/// 解决内存泄漏、资源释放不及时、鲁棒性不足等问题
/// </summary>
/// <param name="video">视频实体(修正拼写错误:viedo -> video</param>
/// <returns>视频流响应</returns>
public async Task<IActionResult> PlayVideoAsync(DouyinVideo video)
{
// 空值校验
if (video == null)
{
return ApiResult.Fail("视频信息不能为空");
}
// 1. 获取完整物理路径并校验
string videoFullPath = video.VideoSavePath;
if (string.IsNullOrWhiteSpace(videoFullPath))
{
return ApiResult.Fail("视频保存路径不能为空");
}
// 安全校验:防止路径遍历攻击
try
{
videoFullPath = Path.GetFullPath(videoFullPath);
}
catch
{
return ApiResult.Fail("视频路径格式非法");
}
// 2. 验证文件是否存在
if (!System.IO.File.Exists(videoFullPath))
@@ -88,36 +161,117 @@ namespace dy.net.Controllers
return ApiResult.Fail($"视频文件不存在:{videoFullPath}");
}
// 3. 获取文件信息(大小、类型)
var fileInfo = new FileInfo(videoFullPath);
long fileSize = fileInfo.Length;
string contentType = GetContentType(videoFullPath); // 自动识别视频 MIME 类型
// 4. 处理分片请求(前端视频标签自动发起,支持断点续传)
if (Request.Headers.ContainsKey("Range") && long.TryParse(Request.Headers.Range.ToString().Split('=')[1].Split('-')[0], out long start))
try
{
// 分片起始位置(前端请求的起始字节
long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1); // 每片 2MB(可调整)
long chunkSize = end - start + 1;
// 3. 获取文件信息(使用using确保FileInfo资源释放
var fileInfo = new FileInfo(videoFullPath);
long fileSize = fileInfo.Length;
// 5. 设置分片响应头
Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}");
Response.Headers.Add("Accept-Ranges", "bytes");
Response.Headers.Add("Content-Length", chunkSize.ToString());
// 校验空文件
if (fileSize == 0)
{
return ApiResult.Fail($"视频文件为空:{videoFullPath}");
}
// 6. 读取分片并返回流
var stream = new FileStream(videoFullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
stream.Seek(start, SeekOrigin.Begin);
return new FileStreamResult(stream, contentType);
string contentType = GetContentType(videoFullPath);
// 4. 处理分片请求(Range
if (Request.Headers.ContainsKey("Range"))
{
return await HandleRangeRequestAsync(videoFullPath, fileSize, contentType);
}
else
{
// 完整文件请求(启用分片处理,兼容前端断点续传)
return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true);
}
}
else
catch (UnauthorizedAccessException)
{
// 完整文件请求(兼容旧浏览器)
return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true);
return ApiResult.Fail($"没有权限访问视频文件:{videoFullPath}");
}
catch (IOException ex)
{
// 记录日志(建议添加日志框架,如Serilog/NLog
// _logger.LogError(ex, "读取视频文件失败:{Path}", videoFullPath);
return ApiResult.Fail($"读取视频文件失败:{ex.Message}");
}
catch (Exception ex)
{
// _logger.LogError(ex, "视频播放接口异常:{Path}", videoFullPath);
return ApiResult.Fail($"服务器内部错误:{ex.Message}");
}
}
/// <summary>
/// 处理分片请求(Range),异步安全处理流
/// </summary>
/// <param name="filePath">文件路径</param>
/// <param name="fileSize">文件总大小</param>
/// <param name="contentType">内容类型</param>
/// <returns>分片响应</returns>
private async Task<IActionResult> HandleRangeRequestAsync(string filePath, long fileSize, string contentType)
{
string rangeHeader = Request.Headers["Range"].ToString();
// 安全解析Range头
if (!rangeHeader.StartsWith("bytes="))
{
// 416 - 请求的范围无法满足
Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable;
Response.Headers.Add("Content-Range", $"bytes */{fileSize}");
return new EmptyResult();
}
// 解析起始位置
string[] rangeParts = rangeHeader.Split('=')[1].Split('-');
if (!long.TryParse(rangeParts[0], out long start) || start < 0 || start >= fileSize)
{
Response.StatusCode = StatusCodes.Status416RequestedRangeNotSatisfiable;
Response.Headers.Add("Content-Range", $"bytes */{fileSize}");
return new EmptyResult();
}
// 计算分片结束位置(每片2MB,可配置)
long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1);
long chunkSize = end - start + 1;
// 设置206分片响应头
Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}");
Response.Headers.Add("Accept-Ranges", "bytes");
Response.Headers.Add("Content-Length", chunkSize.ToString());
Response.ContentType = contentType;
// 核心改进:使用异步流 + using确保释放(通过管道直接写入响应流)
await using var fileStream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 8192, // 增大缓冲区提升性能,减少IO次数
FileOptions.Asynchronous | FileOptions.SequentialScan); // 顺序扫描优化
// 定位到分片起始位置
fileStream.Seek(start, SeekOrigin.Begin);
// 直接写入响应流,避免FileStreamResult的延迟释放问题
var buffer = new byte[8192];
long remainingBytes = chunkSize;
while (remainingBytes > 0)
{
int bytesRead = await fileStream.ReadAsync(buffer, 0, (int)Math.Min(remainingBytes, buffer.Length));
if (bytesRead == 0) break;
await Response.Body.WriteAsync(buffer.AsMemory(0, bytesRead));
remainingBytes -= bytesRead;
}
await Response.Body.FlushAsync();
return new EmptyResult();
}
/// <summary>
/// 播放视频
@@ -144,7 +298,7 @@ namespace dy.net.Controllers
return ApiResult.Fail($"视频地址无效");
}
return PlayViedo(viedo);
return await PlayVideoAsync(viedo);
}
catch (Exception ex)
{
@@ -155,16 +309,12 @@ namespace dy.net.Controllers
/// <summary>
/// 辅助方法:根据文件名获取 MIME 类型(确保前端正确识别视频格式)
/// </summary>
private string GetContentType(string filename)
private static string GetContentType(string filename)
{
string extension = Path.GetExtension(filename).ToLowerInvariant();
return extension switch
{
".mp4" => "video/mp4",
".webm" => "video/webm",
".ogg" => "video/ogg",
".mov" => "video/quicktime",
".avi" => "video/x-msvideo",
_ => "application/octet-stream" // 默认二进制流
};
}
@@ -277,7 +427,7 @@ namespace dy.net.Controllers
//private async Task<(bool flowControl, IActionResult value)> BatchDeleteVideos(List<DouyinVideo> videos)
//{
// return (flowControl: true, value: null);
//}
@@ -287,7 +437,7 @@ namespace dy.net.Controllers
/// <param name="top"></param>
/// <returns></returns>
[HttpGet("top{top}")]
public async Task<IActionResult> GetLastSyncTop([FromRoute]int top = 5)
public async Task<IActionResult> GetLastSyncTop([FromRoute] int top = 5)
{
return ApiResult.Success(await douyinVideoService.GetLastSyncTop(top));
}
@@ -310,7 +460,7 @@ namespace dy.net.Controllers
[HttpGet("renfo")]
public async Task<IActionResult> ReCreateNfo()
{
var videos= await douyinVideoService.GetAllAsync();
var videos = await douyinVideoService.GetAllAsync();
if (videos == null || videos.Count == 0)
{
return ApiResult.Success("暂无视频数据需要生成NFO文件");
@@ -330,7 +480,7 @@ namespace dy.net.Controllers
}
catch (Exception singleEx)
{
Serilog.Log.Error($"刮削视频(Path{video.VideoSavePath})生成NFO失败:{singleEx.Message}");
Serilog.Log.Error($"刮削视频(Path{video.VideoSavePath})生成NFO失败:{singleEx.Message}");
}
}
}
@@ -342,5 +492,33 @@ namespace dy.net.Controllers
return ApiResult.Success();
}
/// <summary>
/// 获取7天视频同步趋势数据(曲线图)
/// </summary>
/// <returns>7天图表数据列表</returns>
[HttpGet("chart")]
public async Task<IActionResult> Chart()
{
try
{
var chartData = await douyinVideoService.GetChartData();
return ApiResult.Success(chartData);
}
catch (Exception ex)
{
return ApiResult.Fail(ex.Message);
}
}
[HttpGet("/Move")]
public async Task <IActionResult> Move()
{
await douyinVideoService.HandOldFolderVideos();
return ApiResult.Success(DateTime.Now);
}
}
}
+1 -2
View File
@@ -220,12 +220,11 @@ namespace dy.net
commonService.UpdateAllCookieSyncedToZero();
// 初始化配置
var config = commonService.InitConfig();
if (!isDevelopment)
//if (!isDevelopment)
{
// 启动定时任务
var quartzJobService = services.GetRequiredService<DouyinQuartzJobService>();
quartzJobService.InitOrReStartAllJobs(config?.Cron <= 0 ? "30" : config.Cron.ToString());
DouyinHttpHelper.GetTenImage(Appsettings.Get("tagName"));//查询镜像版本
}
// 初始化Cookie
var deploy= Appsettings.Get("deploy");
@@ -2,8 +2,8 @@
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<_PublishTargetUrl>E:\gitea\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl>
<History>True|2026-01-19T02:19:44.8997385Z||;True|2026-01-19T10:11:10.2305307+08:00||;True|2026-01-17T00:52:08.8180346+08:00||;False|2026-01-17T00:52:03.1829000+08:00||;True|2026-01-17T00:45:04.0498090+08:00||;True|2026-01-16T21:00:57.7239379+08:00||;True|2026-01-16T21:00:03.3912989+08:00||;True|2026-01-16T20:56:08.5505592+08:00||;True|2026-01-16T20:46:32.3067302+08:00||;True|2026-01-15T22:18:57.9835738+08:00||;True|2026-01-15T22:07:25.4753938+08:00||;True|2026-01-15T22:07:14.8243754+08:00||;True|2026-01-15T21:40:13.2613927+08:00||;True|2026-01-15T20:57:56.6291682+08:00||;True|2026-01-15T20:57:47.1363536+08:00||;True|2026-01-15T20:08:44.4710883+08:00||;True|2026-01-15T20:04:51.8284314+08:00||;False|2026-01-15T20:04:45.3258155+08:00||;True|2026-01-15T19:59:11.7907672+08:00||;True|2026-01-15T01:10:48.9066941+08:00||;True|2026-01-15T01:03:23.6216975+08:00||;True|2026-01-15T01:03:16.3364591+08:00||;False|2026-01-15T01:03:10.5654436+08:00||;True|2026-01-15T00:49:21.6559622+08:00||;True|2026-01-15T00:10:42.7288439+08:00||;True|2026-01-15T00:06:28.4241682+08:00||;True|2026-01-13T22:34:15.2372411+08:00||;True|2026-01-13T22:26:46.4522947+08:00||;True|2026-01-13T22:15:56.9735179+08:00||;True|2026-01-13T22:15:51.8250677+08:00||;False|2026-01-13T22:15:46.6608690+08:00||;True|2026-01-13T22:03:06.4683237+08:00||;False|2026-01-13T22:03:01.2036027+08:00||;True|2026-01-13T21:36:05.8776209+08:00||;True|2026-01-13T21:33:02.0766289+08:00||;False|2026-01-13T21:32:57.1976141+08:00||;True|2026-01-13T19:14:35.2519439+08:00||;True|2026-01-13T15:07:50.0836745+08:00||;False|2026-01-13T15:06:35.5929685+08:00||;True|2026-01-13T10:21:41.6012776+08:00||;True|2026-01-13T09:57:25.4788847+08:00||;True|2026-01-13T09:53:14.4900360+08:00||;True|2026-01-13T09:23:53.6465295+08:00||;True|2026-01-12T22:04:14.5886955+08:00||;False|2026-01-12T22:04:08.2008101+08:00||;True|2026-01-12T21:53:46.9947176+08:00||;True|2026-01-12T21:11:15.8358024+08:00||;True|2026-01-12T21:09:51.8663228+08:00||;True|2026-01-11T21:25:07.5052845+08:00||;False|2026-01-11T21:24:27.5744557+08:00||;True|2026-01-11T19:59:15.4734611+08:00||;False|2026-01-11T19:59:04.9543339+08:00||;True|2026-01-11T18:51:30.6649269+08:00||;True|2026-01-08T21:07:57.5094466+08:00||;True|2026-01-08T14:51:18.2266231+08:00||;True|2026-01-08T14:31:55.1137120+08:00||;True|2026-01-08T00:16:24.4451490+08:00||;True|2026-01-08T00:14:20.7430793+08:00||;True|2026-01-08T00:07:46.7228194+08:00||;True|2026-01-07T23:52:13.3290082+08:00||;True|2026-01-07T23:49:28.4838650+08:00||;True|2026-01-07T23:47:45.1936189+08:00||;True|2026-01-07T23:35:09.7818611+08:00||;True|2026-01-07T23:20:43.7462863+08:00||;True|2026-01-07T23:04:39.5140429+08:00||;False|2026-01-07T23:04:36.5367611+08:00||;True|2026-01-07T22:53:14.6013449+08:00||;True|2026-01-07T22:49:59.9549006+08:00||;True|2026-01-07T22:36:40.0254856+08:00||;False|2026-01-07T22:36:28.8275903+08:00||;True|2026-01-07T21:59:51.4355171+08:00||;True|2026-01-03T21:38:51.4307034+08:00||;True|2026-01-02T19:09:16.9668906+08:00||;False|2026-01-02T19:09:11.7496369+08:00||;True|2026-01-02T15:42:08.2215697+08:00||;True|2026-01-02T09:46:56.1654861+08:00||;True|2026-01-02T09:35:28.0211225+08:00||;True|2026-01-01T23:07:01.9022045+08:00||;False|2026-01-01T23:06:56.0537216+08:00||;True|2026-01-01T22:16:10.2974067+08:00||;True|2026-01-01T22:16:06.2123787+08:00||;False|2026-01-01T22:15:33.3626979+08:00||;True|2026-01-01T22:01:30.7161900+08:00||;True|2026-01-01T21:10:19.3664263+08:00||;True|2026-01-01T21:09:38.6071080+08:00||;True|2025-12-30T11:45:54.5543034+08:00||;True|2025-12-30T09:19:08.3178124+08:00||;True|2025-12-29T18:57:29.7032246+08:00||;True|2025-12-27T14:52:24.4780776+08:00||;False|2025-12-27T14:52:19.5635794+08:00||;True|2025-12-27T14:48:01.6252748+08:00||;False|2025-12-27T14:47:55.7976192+08:00||;True|2025-12-27T14:38:23.9723838+08:00||;True|2025-12-27T13:00:29.8583858+08:00||;True|2025-12-26T22:18:42.4015637+08:00||;True|2025-12-26T22:10:58.5274572+08:00||;True|2025-12-26T22:06:26.3129600+08:00||;True|2025-12-26T22:03:55.6718618+08:00||;False|2025-12-26T22:03:48.3809954+08:00||;True|2025-12-26T22:02:33.1840390+08:00||;</History>
<_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl>
<History>True|2026-01-21T17:37:53.6972395Z||;True|2026-01-22T01:34:14.2142463+08:00||;True|2026-01-22T01:32:50.9228911+08:00||;True|2026-01-22T01:29:43.8871986+08:00||;True|2026-01-22T01:29:40.7838709+08:00||;True|2026-01-22T01:27:34.4763970+08:00||;True|2026-01-22T01:26:55.0117367+08:00||;True|2026-01-22T01:23:19.4789587+08:00||;True|2026-01-19T10:19:44.8997385+08:00||;True|2026-01-19T10:11:10.2305307+08:00||;True|2026-01-17T00:52:08.8180346+08:00||;False|2026-01-17T00:52:03.1829000+08:00||;True|2026-01-17T00:45:04.0498090+08:00||;True|2026-01-16T21:00:57.7239379+08:00||;True|2026-01-16T21:00:03.3912989+08:00||;True|2026-01-16T20:56:08.5505592+08:00||;True|2026-01-16T20:46:32.3067302+08:00||;True|2026-01-15T22:18:57.9835738+08:00||;True|2026-01-15T22:07:25.4753938+08:00||;True|2026-01-15T22:07:14.8243754+08:00||;True|2026-01-15T21:40:13.2613927+08:00||;True|2026-01-15T20:57:56.6291682+08:00||;True|2026-01-15T20:57:47.1363536+08:00||;True|2026-01-15T20:08:44.4710883+08:00||;True|2026-01-15T20:04:51.8284314+08:00||;False|2026-01-15T20:04:45.3258155+08:00||;True|2026-01-15T19:59:11.7907672+08:00||;True|2026-01-15T01:10:48.9066941+08:00||;True|2026-01-15T01:03:23.6216975+08:00||;True|2026-01-15T01:03:16.3364591+08:00||;False|2026-01-15T01:03:10.5654436+08:00||;True|2026-01-15T00:49:21.6559622+08:00||;True|2026-01-15T00:10:42.7288439+08:00||;True|2026-01-15T00:06:28.4241682+08:00||;True|2026-01-13T22:34:15.2372411+08:00||;True|2026-01-13T22:26:46.4522947+08:00||;True|2026-01-13T22:15:56.9735179+08:00||;True|2026-01-13T22:15:51.8250677+08:00||;False|2026-01-13T22:15:46.6608690+08:00||;True|2026-01-13T22:03:06.4683237+08:00||;False|2026-01-13T22:03:01.2036027+08:00||;True|2026-01-13T21:36:05.8776209+08:00||;True|2026-01-13T21:33:02.0766289+08:00||;False|2026-01-13T21:32:57.1976141+08:00||;True|2026-01-13T19:14:35.2519439+08:00||;True|2026-01-13T15:07:50.0836745+08:00||;False|2026-01-13T15:06:35.5929685+08:00||;True|2026-01-13T10:21:41.6012776+08:00||;True|2026-01-13T09:57:25.4788847+08:00||;True|2026-01-13T09:53:14.4900360+08:00||;True|2026-01-13T09:23:53.6465295+08:00||;True|2026-01-12T22:04:14.5886955+08:00||;False|2026-01-12T22:04:08.2008101+08:00||;True|2026-01-12T21:53:46.9947176+08:00||;True|2026-01-12T21:11:15.8358024+08:00||;True|2026-01-12T21:09:51.8663228+08:00||;True|2026-01-11T21:25:07.5052845+08:00||;False|2026-01-11T21:24:27.5744557+08:00||;True|2026-01-11T19:59:15.4734611+08:00||;False|2026-01-11T19:59:04.9543339+08:00||;True|2026-01-11T18:51:30.6649269+08:00||;True|2026-01-08T21:07:57.5094466+08:00||;True|2026-01-08T14:51:18.2266231+08:00||;True|2026-01-08T14:31:55.1137120+08:00||;True|2026-01-08T00:16:24.4451490+08:00||;True|2026-01-08T00:14:20.7430793+08:00||;True|2026-01-08T00:07:46.7228194+08:00||;True|2026-01-07T23:52:13.3290082+08:00||;True|2026-01-07T23:49:28.4838650+08:00||;True|2026-01-07T23:47:45.1936189+08:00||;True|2026-01-07T23:35:09.7818611+08:00||;True|2026-01-07T23:20:43.7462863+08:00||;True|2026-01-07T23:04:39.5140429+08:00||;False|2026-01-07T23:04:36.5367611+08:00||;True|2026-01-07T22:53:14.6013449+08:00||;True|2026-01-07T22:49:59.9549006+08:00||;True|2026-01-07T22:36:40.0254856+08:00||;False|2026-01-07T22:36:28.8275903+08:00||;True|2026-01-07T21:59:51.4355171+08:00||;True|2026-01-03T21:38:51.4307034+08:00||;True|2026-01-02T19:09:16.9668906+08:00||;False|2026-01-02T19:09:11.7496369+08:00||;True|2026-01-02T15:42:08.2215697+08:00||;True|2026-01-02T09:46:56.1654861+08:00||;True|2026-01-02T09:35:28.0211225+08:00||;True|2026-01-01T23:07:01.9022045+08:00||;False|2026-01-01T23:06:56.0537216+08:00||;True|2026-01-01T22:16:10.2974067+08:00||;True|2026-01-01T22:16:06.2123787+08:00||;False|2026-01-01T22:15:33.3626979+08:00||;True|2026-01-01T22:01:30.7161900+08:00||;True|2026-01-01T21:10:19.3664263+08:00||;True|2026-01-01T21:09:38.6071080+08:00||;True|2025-12-30T11:45:54.5543034+08:00||;True|2025-12-30T09:19:08.3178124+08:00||;True|2025-12-29T18:57:29.7032246+08:00||;True|2025-12-27T14:52:24.4780776+08:00||;False|2025-12-27T14:52:19.5635794+08:00||;True|2025-12-27T14:48:01.6252748+08:00||;False|2025-12-27T14:47:55.7976192+08:00||;</History>
<LastFailureDetails />
</PropertyGroup>
</Project>
+16 -19
View File
@@ -36,25 +36,22 @@ Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,
### 1.1 提取抖音 Cookie
### 1.1 提取`Cookie`以及 `sec_uer_id`
1. 打开 **抖音网页版** (https://www.douyin.com/) 并登录目标账号;
2. 进入「我的收藏」页面,确保页面加载完成;
3. `F12` 打开浏览器「开发者工具」,切换到「Network (网络)」标签;
4. 刷新页面,在搜索框中输入 `v1/web/aweme/listcollection` 筛选请求;
5. 点击任意一条筛选结果,在右侧「Headers (标头)」中找到 `Cookie` 字段,**完整复制整段内容**(不可删减字符)。
2. 进入个人主页,按下F12进入开发者模式、并切换到 `网络`(也可能叫`network`).
3. 在筛选框中输入`/follow`.
4. 点击自己头像边上的 `关注` 按钮、会弹出你的关注列表,然后在右侧网络请求里面会出现多个请求,随便选一个.
4. 在请求的标签里面切换到`负载`(也可能叫`payload`)
5. 找到sec_user_id,复制值即可
6. 在请求的标签里面切换到`标头`(也可能叫`Headers`)
7. 往下拉,直到出现`Cookie` ,复制完整的值,注意前后不要带换行符,很多人会多复制个换行符出来.
![获取Cookie步骤](docs/getcookies.png)
![cookie](docs/findcookie_1_2_3.png)
![cookie](docs/findcookie_4.png)
![cookie](docs/findcookie_5.png)
![cookie](docs/findcookie_6_7.png)
### 1.2 提取的`sec_user_id`
- **个人 sec_user_id**(同步自己喜欢用):
1.进入自己的抖音主页:
- 方式:按 `F12` 点击`Network``网络` 筛选器里面填`web/user/following/list` 然后 查看请求的 `payload``负载` 即可找到`sec_user_id`;
2.进入抖音主页后随便点一个自己的作品,然后你的名字,进入目标博主主页;
方式:直接复制地址栏中 `user/``?from_tab_name` 中间部分内容即是博主的 `sec_user_id`
![获取Cookie步骤](docs/getmysecuid.png)
### 1.3 提取 博主的`sec_user_id`以及博主的uid
### 1.2 提取 博主的`sec_user_id`以及博主的uid
- **对于想下载博主视频,但是又不想关注博主,需要用到**
- 1.进入博主主页,按`F12` 点击`Network``网络` 筛选器里面填`/web/aweme/post` 然后切到`预览``preview` 展开 json数据结果 找到`aweme_list` 然后随便点开其中一个子项 即可找到`author_user_id` 这便是博主的uid 。后续在关注列表中,需要手动添加非关注博主同步视频时将会要用到。
![获取Cookie步骤](docs/getuperuid.png)
@@ -96,7 +93,7 @@ Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,
| 镜像标签 | 架构 |
| ----------------- | -------------- |
| `beta_1.9.9` | x86_64 (amd64) |
| `beta_2.0.0` | x86_64 (amd64) |
| `arm_1.9.8` | ARM64 |
@@ -114,7 +111,7 @@ docker run -d --restart=always \
-v /opt/dysync/uper:/app/uper \
-p 10101:10101 \
--name dysync2026 \
ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_1.9.9
ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_2.0.0
```
@@ -129,7 +126,7 @@ version: '3.8'
services:
dysync:
image: ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_1.9.9
image: ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_2.0.0
container_name: dysync2026 # 容器名称
restart: unless-stopped # 始终重启容器,除非容器被手动停止或Docker服务停止
ports:
+109 -76
View File
@@ -29,7 +29,7 @@ columns.value = [
{ title: '收藏路径', dataIndex: 'savePath' },
{ title: '喜欢路径', dataIndex: 'favSavePath' },
{ title: '博主路径', dataIndex: 'upSavePath' },
{ title: '图文视频', dataIndex: 'imgSavePath' },
// { title: '图文视频', dataIndex: 'imgSavePath' },
// { title: 'Cookie', dataIndex: 'cookies' },
{ title: '状态', dataIndex: 'status', width: 180 },
{ title: '操作', dataIndex: 'edit', width: 200 }, // 加宽操作列宽度
@@ -54,11 +54,13 @@ type DataItem = {
upSecUserIdsJson?: UpSecUserIdItem[];
upSecUserIds?: string;
upSavePath?: string;
imgSavePath?: string;
// imgSavePath?: string;
useSinglePath?: boolean; // 新增:是否全部用一个地址
useCollectFolder?: boolean;
downMix?: boolean;
downSeries?: boolean;
// mixPath?: string;
// seriesPath?: string;
};
const loading = ref(false);
@@ -117,11 +119,13 @@ const newCookie = (cookie?: DataItem) => {
cookie.id = '0';
cookie.upSecUserIdsJson = undefined;
cookie.upSavePath = undefined;
cookie.imgSavePath = undefined;
// cookie.imgSavePath = undefined;
cookie.useSinglePath = false; // 新增:默认不使用单一路径
cookie.useCollectFolder = false; //是否按收藏夹来下载。
cookie.downMix = false; //是否下载收藏夹的合集
cookie.downSeries = false; //是否下载短剧
// cookie.mixPath = undefined; //合集存储路径
// cookie.seriesPath = undefined; //短剧存储路径
return cookie;
};
@@ -141,7 +145,7 @@ watch(
if (useSinglePath && newSavePath) {
form.favSavePath = newSavePath;
form.upSavePath = newSavePath;
form.imgSavePath = newSavePath;
// form.imgSavePath = newSavePath;
}
},
{ immediate: true }
@@ -220,6 +224,7 @@ const deleted = (id: string) => {
};
function edit(record: DataItem) {
cookieId.value = record.id;
editRecord.value = record;
console.log(record);
copyObject(form, record);
@@ -316,6 +321,10 @@ onMounted(() => {
const showDrawer = ref(false);
// 抽屉类型(区分收藏夹/合集/短剧)
type DrawerType = 'collect' | 'mix' | 'series';
const cookieId = ref('');
const cateType = ref(5);
const drawerType = ref<DrawerType>('collect');
// 抽屉滚动容器引用(用于监听触底分页)
const drawerScrollRef = ref<HTMLDivElement | null>(null);
@@ -332,28 +341,32 @@ const drawerPagination = reactive({
// ========== 定义抽屉数据项接口 ==========
interface DrawerItem {
Id: string; // 不显示
Name: string; // 名称
SaveFolder: string; // 保存文件夹
Sync: boolean; // 是否同步
CoverUrl: string; // 封面
CookieId: string; // 不显示
XId: string; // 不显示
id: string; // 不显示
name: string; // 名称
saveFolder: string; // 保存文件夹
sync: boolean; // 是否同步
coverUrl: string; // 封面
cookieId: string; // 不显示
xId: string; // 不显示
total: number;
}
// ========== 3个打开抽屉的方法 ==========
const openCollectFolderSetModal = () => {
drawerType.value = 'collect';
cateType.value = 5;
openCommonDrawer();
};
const openMixDownSetModal = () => {
drawerType.value = 'mix';
cateType.value = 6;
openCommonDrawer();
};
const openSeriesDownSetModal = () => {
drawerType.value = 'series';
cateType.value = 7;
openCommonDrawer();
};
@@ -401,36 +414,32 @@ const handleDrawerScroll = () => {
}
};
// ========== 加载抽屉数据(模拟接口请求,可替换为真实接口) ==========
// ========== 加载抽屉数据(
const loadDrawerData = () => {
if (drawerPagination.loading || !drawerPagination.hasMore) return;
drawerPagination.loading = true;
// 模拟接口请求(实际项目中替换为真实API调用)
setTimeout(() => {
// 模拟数据(可根据drawerType返回不同类型数据)
const mockData: DrawerItem[] = Array.from({ length: drawerPagination.pageSize }, (_, index) => ({
Id: `${drawerPagination.current}-${index}`,
Name: `${getDrawerTypeName()} ${(drawerPagination.current - 1) * drawerPagination.pageSize + index + 1}`,
SaveFolder: `./${drawerType.value}/${(drawerPagination.current - 1) * drawerPagination.pageSize + index + 1}`,
Sync: Math.random() > 0.5,
CoverUrl: `https://picsum.photos/120/180?random=${
// 改为竖向图片尺寸 120x180
(drawerPagination.current - 1) * drawerPagination.pageSize + index + 1
}`,
CookieId: form.id || '0',
XId: `X-${drawerPagination.current}-${index}`,
}));
// 拼接数据列表
drawerDataList.value = [...drawerDataList.value, ...mockData];
// 更新分页状态
drawerPagination.total = 100; // 模拟总条数
drawerPagination.loading = false;
// 判断是否还有更多数据
drawerPagination.hasMore = drawerDataList.value.length < drawerPagination.total;
}, 800);
useApiStore()
.CatePageList({
cookieId: cookieId.value,
cateType: cateType.value,
})
.then((res) => {
if (res.code === 0) {
drawerDataList.value = [...drawerDataList.value, ...res.data.data];
drawerPagination.loading = false;
// 更新分页状态
drawerPagination.total = res.data.total;
drawerPagination.loading = false;
// 判断是否还有更多数据
drawerPagination.hasMore = drawerDataList.value.length < drawerPagination.total;
} else {
message.error(res.message);
}
})
.finally(() => {
drawerPagination.loading = false;
});
};
// ========== 获取抽屉类型名称(用于页面展示) ==========
@@ -443,15 +452,15 @@ const getDrawerTypeName = () => {
case 'series':
return '短剧';
default:
return '数据';
return '';
}
};
// ========== 切换同步状态(可根据需求对接真实接口) ==========
const toggleDrawerItemSync = (item: DrawerItem, index: number) => {
if (drawerDataList.value[index]) {
drawerDataList.value[index].Sync = !item.Sync;
console.log(`切换${getDrawerTypeName()}${item.Name}】的同步状态为:${!item.Sync}`);
drawerDataList.value[index].sync = !item.sync;
console.log(`切换${getDrawerTypeName()}${item.name}】的同步状态为:${!item.sync}`);
}
};
// ========== 关闭抽屉清理资源 ==========
@@ -470,12 +479,20 @@ const saveDrawerData = () => {
message.info('暂无需要保存的配置数据');
return;
}
// 模拟保存逻辑(实际项目中可替换为真实接口,提交 drawerDataList.value 数据)
drawerPagination.loading = true;
setTimeout(() => {
drawerPagination.loading = false;
message.success(`${getDrawerTypeName()}配置保存成功`);
}, 800);
useApiStore()
.BatchSaveCate(drawerDataList.value)
.then((res) => {
if (res.code === 0) {
showDrawer.value = false;
message.success('保存成功');
} else {
message.error(res.message);
}
})
.finally(() => {
drawerPagination.loading = false;
});
};
</script>
@@ -504,14 +521,14 @@ const saveDrawerData = () => {
<!-- 收藏的存储路径 -->
<a-form-item label="收藏的存储路径" name="savePath">
<div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.savePath" style="width: 200px;" />
<a-input v-model:value="form.savePath" class="form-item-div" />
<a-alert message="不想同步收藏的视频就空着" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
</div>
</a-form-item>
<!-- 新增是否全部用一个地址开关 -->
<a-form-item v-if="form.savePath&&form.savePath.length>0" label="是否统一存储路径" name="useSinglePath">
<div style="display: flex; align-items: center; gap: 12px;">
<div style="width: 200px;">
<div class="form-item-div">
<a-switch v-model:checked="form.useSinglePath" :checked-value="true" :un-checked-value="false" size="default" />
<span style="margin-left:10px;">{{form.useSinglePath ?'是':'否'}}</span>
</div>
@@ -519,11 +536,28 @@ const saveDrawerData = () => {
<a-alert message="开启后,所有视频都存储在收藏视频存储的路径,如果是容器部署:docker-compose配置,此时只需要映射一个路径' " :type="form.useSinglePath?'success':'info'" size="small" style="flex: 1; margin-bottom: 0;" />
</div>
</a-form-item>
<!-- 喜欢的存储路径 -->
<a-form-item label="喜欢的存储路径" name="favSavePath">
<div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.favSavePath" :disabled="form.useSinglePath" placeholder="" class="form-item-div" />
<a-alert message="不想同步喜欢的视频就空着" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
</div>
</a-form-item>
<!-- 关注的存储路径 -->
<a-form-item label="关注的存储路径" name="upSavePath">
<div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.upSavePath" :disabled="form.useSinglePath" placeholder="" class="form-item-div" />
<a-alert message="不想同步关注列表博主的视频就空着" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
</div>
</a-form-item>
<a-form-item v-if="form.savePath&&form.savePath.length>0" label="下载收藏夹" name="useCollectFolder">
<div style="display: flex; align-items: center; gap: 12px;">
<div style="width: 200px;">
<div class="form-item-div">
<a-switch v-model:checked="form.useCollectFolder" :checked-value="true" :un-checked-value="false" size="default" />
<span style="margin-left:10px;">{{ form.useCollectFolder ? '是' : '否' }}</span>
<a-button @click="openCollectFolderSetModal" shape="circle" type="dashed" style="margin-left:10px;" v-if="form.useCollectFolder">
<setting-outlined />
</a-button>
@@ -535,9 +569,11 @@ const saveDrawerData = () => {
<a-form-item v-if="form.savePath&&form.savePath.length>0" label="下载合集" name="downMix">
<div style="display: flex; align-items: center; gap: 12px;">
<div style="width: 200px;">
<div class="form-item-div">
<a-switch v-model:checked="form.downMix" :checked-value="true" :un-checked-value="false" size="default" />
<span style="margin-left:10px;">{{ form.downMix ? '是' : '否' }}</span>
<!-- <a-input v-model:value="form.mixPath" class="form-item-div-input" /> -->
<a-button @click="openMixDownSetModal" shape="circle" type="dashed" style="margin-left:10px;" v-if="form.downMix"> <setting-outlined />
</a-button>
</div>
@@ -548,9 +584,11 @@ const saveDrawerData = () => {
<a-form-item v-if="form.savePath&&form.savePath.length>0" label="下载短剧" name="downSeries">
<div style="display: flex; align-items: center; gap: 12px;">
<div style="width: 200px;">
<div class="form-item-div">
<a-switch v-model:checked="form.downSeries" :checked-value="true" :un-checked-value="false" size="default" />
<span style="margin-left:10px;">{{ form.downSeries ? '是' : '否' }}</span>
<!-- <a-input v-model:value="form.seriesPath" class="form-item-div-input" /> -->
<a-button @click="openSeriesDownSetModal" shape="circle" type="dashed" style="margin-left:10px;" v-if="form.downSeries"> <setting-outlined />
</a-button>
</div>
@@ -559,29 +597,13 @@ const saveDrawerData = () => {
</div>
</a-form-item>
<!-- 喜欢的存储路径 -->
<a-form-item label="喜欢的存储路径" name="favSavePath">
<div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.favSavePath" :disabled="form.useSinglePath" placeholder="" style="width: 200px;" />
<a-alert message="不想同步喜欢的视频就空着" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
</div>
</a-form-item>
<!-- 关注的存储路径 -->
<a-form-item label="关注的存储路径" name="upSavePath">
<div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.upSavePath" :disabled="form.useSinglePath" placeholder="" style="width: 200px;" />
<a-alert message="不想同步关注列表博主的视频就空着" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
</div>
</a-form-item>
<!-- 图文的存储路径 -->
<a-form-item label="图文的存储路径" name="imgSavePath">
<!-- <a-form-item label="图文的存储路径" name="imgSavePath">
<div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.imgSavePath" :disabled="form.useSinglePath" style="width: 200px;" />
<a-alert message="如果系统配置页面开启了同步图文视频,且开启了单独存储,则必填!!!" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
</div>
</a-form-item>
</a-form-item> -->
<!-- 同步状态开关 -->
<a-form-item label="同步状态" name="status">
@@ -614,35 +636,39 @@ const saveDrawerData = () => {
<!-- 卡片网格展示 - 优化布局横向一行展示名称保存路径 -->
<a-card v-else :bordered="false" class="drawer-card-container grid-container">
<a-card-grid v-for="(item, index) in drawerDataList" :key="item.Id" class="drawer-card-grid">
<a-card-grid v-for="(item, index) in drawerDataList" :key="item.id" class="drawer-card-grid">
<!-- 竖向封面移除预览功能仅保留基础展示 -->
<div class="grid-cover vertical-cover" v-if="drawerType!='collect'">
<a-image :preview="false" :src="item.CoverUrl" fallback="https://placeholder.picsum.photos/120/180" fit="cover" />
<a-image :preview="false" :src="item.coverUrl" fit="cover" />
</div>
<!-- 名称横向一行展示标签 + 内容 在同一行 -->
<div class="grid-item horizontal-item name">
<label class="drawer-label">名称</label>
<span>{{ item.Name || '未命名' }}</span>
<span>{{ item.name || '未命名' }}</span>
</div>
<!-- 保存文件夹横向一行展示标签 + 输入框 在同一行 -->
<div class="grid-item horizontal-item save-folder">
<label class="drawer-label">保存路径</label>
<a-input v-model:value="item.SaveFolder" size="small" placeholder="请输入保存路径" class="save-path-input" />
<label class="drawer-label">保存</label>
<a-input v-model:value="item.saveFolder" size="small" placeholder="默认用名称作文件夹" class="save-path-input" />
</div>
<div class="grid-item horizontal-item name">
<label class="drawer-label">集数</label>
<span>{{ item.total || '0' }}</span>
</div>
<!-- 同步开关保持原有逻辑横向对齐 -->
<div class="grid-item horizontal-item sync-switch">
<label class="drawer-label">是否同步</label>
<a-switch :checked="item.Sync" @change="() => toggleDrawerItemSync(item, index)" size="small" />
<label class="drawer-label">同步</label>
<a-switch :checked="item.sync" @change="() => toggleDrawerItemSync(item, index)" size="small" />
</div>
</a-card-grid>
</a-card>
<!-- 无更多数据提示 -->
<div v-if="!drawerPagination.hasMore && drawerDataList.length > 0" class="no-more-data">
已加载全部{{ getDrawerTypeName() }}数据
<!-- 已加载全部{{ getDrawerTypeName() }}数据 -->
</div>
<!-- 加载下一页中 -->
@@ -756,6 +782,13 @@ const saveDrawerData = () => {
.cookie-content::-webkit-scrollbar-corner {
background: transparent;
}
.form-item-div {
width: 300px;
}
.form-item-div-input {
width: 150px;
margin-left: 10px;
}
/* Firefox 透明滚动条适配 */
.cookie-content {
scrollbar-width: thin;
+5 -5
View File
@@ -22,7 +22,7 @@ type ConfigItem = {
secUserId: string;
status: number;
upSavePath: string;
imgSavePath: string;
// imgSavePath: string;
useSinglePath: boolean; // 非可选
};
@@ -37,7 +37,7 @@ const newConfig = (config?: ConfigItem): ConfigItem => {
secUserId: '',
status: 0,
upSavePath: '',
imgSavePath: '',
// imgSavePath: '',
useSinglePath: false,
};
};
@@ -61,7 +61,7 @@ watch(
if (useSinglePath && newSavePath) {
form.value.favSavePath = newSavePath;
form.value.upSavePath = newSavePath;
form.value.imgSavePath = newSavePath;
// form.value.imgSavePath = newSavePath;
}
},
{ immediate: true }
@@ -216,9 +216,9 @@ const manualCheckForm = (): { pass: boolean; msg: string } => {
<a-input v-model:value="form.upSavePath" placeholder="关注视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item>
<a-form-item v-if="!form.useSinglePath" label="图文存储路径" name="imgSavePath">
<!-- <a-form-item v-if="!form.useSinglePath" label="图文存储路径" name="imgSavePath">
<a-input v-model:value="form.imgSavePath" placeholder="图文视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item>
</a-form-item> -->
<!-- 同步状态开关 -->
<a-form-item label="同步状态" name="status">
+5 -5
View File
@@ -88,7 +88,7 @@
<span>开启后将图片文件和音频文件合成为视频文件</span>
</div>
</a-form-item>
<a-form-item v-if="formState.DownImageVideo" has-feedback label="单独存储" name="ImageViedoSaveAlone" :wrapper-col="{ span: 20 }">
<!-- <a-form-item v-if="formState.DownImageVideo" has-feedback label="单独存储" name="ImageViedoSaveAlone" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.ImageViedoSaveAlone" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
@@ -96,7 +96,7 @@
开启后图文视频统一存入抖音授权 Cookie 配置的目录且需提前配置该存储路径关闭后则按类型分别存入对应文件夹如收藏视频存入收藏视频目录
</span>
</div>
</a-form-item>
</a-form-item> -->
<a-form-item v-if="formState.DownImageVideo" has-feedback label="保留音频" name="DownMp3" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownMp3" />
<div class="flex items-start mt-1 text-sm text-gray-500">
@@ -304,7 +304,7 @@ interface FormState {
LogKeepDay: number;
DownImage: boolean;
DownMp3: boolean;
ImageViedoSaveAlone: boolean;
//ImageViedoSaveAlone: boolean;
FollowedTitleTemplate: string[];
FollowedTitleSeparator: string;
FullFollowedTitleTemplate: string;
@@ -327,7 +327,7 @@ const formState: UnwrapRef<FormState> = reactive({
DownImageVideo: false,
DownMp3: false,
DownImage: false,
ImageViedoSaveAlone: true,
// ImageViedoSaveAlone: true,
FollowedTitleTemplate: [],
FollowedTitleSeparator: '',
FullFollowedTitleTemplate: '',
@@ -403,7 +403,7 @@ const getConfig = () => {
FollowedTitleTemplate: parsedTemplateArr,
FollowedTitleSeparator: res.data.followedTitleSeparator || '',
FullFollowedTitleTemplate: fullTemplate,
ImageViedoSaveAlone: res.data.imageViedoSaveAlone,
// ImageViedoSaveAlone: res.data.imageViedoSaveAlone,
AutoDistinct: res.data.autoDistinct,
PriorityLevel: res.data.priorityLevel,
DownDynamicVideo: res.data.downDynamicVideo,
+113 -12
View File
@@ -144,7 +144,7 @@
</a-modal>
<!-- 表格 - 增加复选框和操作列 -->
<a-table :columns="columns" :data-source="dataSource" bordered :pagination="pagination" @change="handleTableChange" :loading="loading" :row-selection="isBatchMode ? rowSelection : null" row-key="id">
<a-table :columns="columns" :data-source="dataSource" bordered :pagination="pagination" @change="handleTableChange" :loading="loading" :row-selection="isBatchMode ? rowSelection : null" row-key="id" :sorter="true">
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'videoTitle'">
<a class="video-title-link" :title="record.videoTitle || '无标题'" @click="handleVideoClick(record)" @mouseenter="handleTitleMouseEnter" @mouseleave="handleTitleMouseLeave">
@@ -206,6 +206,11 @@ interface DataItem {
isMergeVideo?: boolean;
}
// 📌 新增:排序参数类型定义
interface SortParam {
field: string; // 排序字段
order: 'ascend' | 'descend' | ''; // 排序方向:升序/降序/无
}
interface QuaryParam {
dates?: string[];
dates2?: string[];
@@ -216,6 +221,8 @@ interface QuaryParam {
viedoType: string;
fileHash: string;
authorId: string;
sortField?: string; // 📌 新增:排序字段
sortOrder?: string; // 📌 新增:排序方向(asc/desc)
}
// 引入dayjs中文包
@@ -226,6 +233,11 @@ dayjs.locale('zh-cn');
// 批量操作相关状态
const isBatchMode = ref(false); // 批量操作开关状态
const selectedRowKeys = ref<string[]>([]); // 选中的行ID集合
// 📌 新增:排序状态管理
const sortParams = ref<SortParam>({
field: 'syncTime', // 默认排序字段(发布时间)
order: 'descend', // 默认降序(最新的在前)
});
// 表格行选择器类型定义(对齐 Ant Design Vue 3.x 规范)
interface CustomTableRowSelection<T> {
@@ -255,7 +267,6 @@ const rowSelection = computed<CustomTableRowSelection<DataItem>>(() => ({
}),
}));
// 表格列配置(优化:临时注释 fixed: right 避免渲染冲突)
const columns = ref([
{
title: '同步时间',
@@ -268,6 +279,13 @@ const columns = ref([
dataIndex: 'createTimeStr',
align: 'center',
width: 180,
sorter: true,
sortOrder: sortParams.value.field === 'createTime' ? sortParams.value.order : null,
onHeaderCell: () => ({
onClick: () => {
handleSortChange('createTime');
},
}),
},
{
title: '同步类型',
@@ -280,6 +298,13 @@ const columns = ref([
dataIndex: 'author',
align: 'center',
width: 150,
sorter: true,
sortOrder: sortParams.value.field === 'author' ? sortParams.value.order : null,
onHeaderCell: () => ({
onClick: () => {
handleSortChange('author');
},
}),
},
{
title: '视频类型',
@@ -304,10 +329,32 @@ const columns = ref([
key: 'operation',
align: 'center',
width: 180,
// fixed: 'right', // 注释:避免固定列导致的重绘卡顿,如需使用可后续调试
},
]);
// 📌 新增:排序切换方法
const handleSortChange = (field: string) => {
// 如果点击的是当前排序字段,切换排序方向
if (sortParams.value.field === field) {
sortParams.value.order = sortParams.value.order === 'ascend' ? 'descend' : 'ascend';
} else {
// 如果是新的排序字段,默认降序
sortParams.value.field = field;
sortParams.value.order = 'descend';
}
// 更新表格列的排序状态(刷新排序图标)
columns.value.forEach((col) => {
if (col.dataIndex === 'createTimeStr') {
col.sortOrder = sortParams.value.order;
} else {
col.sortOrder = null;
}
});
// 重新查询数据(传递排序参数)
GetRecords();
};
// 监听批量操作开关状态变化,清空选中状态+强制表格重绘
watch(isBatchMode, (isOpen) => {
if (!isOpen) {
@@ -347,6 +394,8 @@ const quaryData: UnwrapRef<QuaryParam> = reactive({
viedoType: '*',
authorId: '',
fileHash: '',
sortField: 'createTime', // 📌 默认排序字段
sortOrder: 'desc', // 📌 默认降序
});
// 分页配置
@@ -438,6 +487,10 @@ const GetRecords = () => {
if (value2.value) {
quaryData.dates2 = value2.value.map((date) => date.format('YYYY-MM-DD')); // 修复:之前误写为value1
}
// 📌 关键:将前端排序状态转换为后端需要的参数
quaryData.sortField = sortParams.value.field;
// 转换排序方向(antd的ascend/descend 转 后端常用的asc/desc
quaryData.sortOrder = sortParams.value.order === 'ascend' ? 'asc' : 'desc';
useApiStore()
.VideoPageList(quaryData)
.then((res) => {
@@ -459,6 +512,45 @@ const GetRecords = () => {
});
};
// 📌 修改表格变化处理:支持分页时保留排序状态
const handleTableChange = (paginationObj: any, filters: any, sorter: any) => {
pagination.value.current = paginationObj.current;
pagination.value.defaultPageSize = paginationObj.pageSize;
// 如果是排序变化(用户点击表头排序)
if (sorter.field) {
// 📌 处理不同列的字段映射
if (sorter.field === 'createTimeStr') {
sortParams.value.field = 'createTime'; // 映射到后端的createTime字段
} else if (sorter.field === 'author') {
sortParams.value.field = 'author'; // 博主列直接使用author字段
} else {
sortParams.value.field = sorter.field;
}
sortParams.value.order = sorter.order;
// 更新所有列的排序状态
columns.value.forEach((col) => {
if (col.dataIndex === sorter.field) {
col.sortOrder = sorter.order;
} else if (col.dataIndex === 'createTimeStr' && sorter.field === 'createTime') {
col.sortOrder = sorter.order;
} else if (col.dataIndex === 'author' && sorter.field === 'author') {
col.sortOrder = sorter.order;
} else {
col.sortOrder = null;
}
});
}
// 分页变化时清空选中状态
if (isBatchMode.value) {
selectedRowKeys.value = [];
}
GetRecords();
};
/** 立即同步 */
const StartNow = () => {
if (isSyncing.value) return;
@@ -496,15 +588,15 @@ const datePicked2 = (_, dateArry: RangeValue) => {
};
/** 表格分页/排序变化事件 */
const handleTableChange = (paginationObj: any) => {
pagination.value.current = paginationObj.current;
pagination.value.defaultPageSize = paginationObj.pageSize;
// 分页变化时清空选中状态(跨页不保留)
if (isBatchMode.value) {
selectedRowKeys.value = [];
}
GetRecords();
};
// const handleTableChange = (paginationObj: any) => {
// pagination.value.current = paginationObj.current;
// pagination.value.defaultPageSize = paginationObj.pageSize;
// // 分页变化时清空选中状态(跨页不保留)
// if (isBatchMode.value) {
// selectedRowKeys.value = [];
// }
// GetRecords();
// };
/** 视频类型切换事件 */
const onViedoTypeChanged = () => {
@@ -1452,4 +1544,13 @@ onMounted(() => {
height: 24px !important;
}
}
/* 📌 新增:博主列排序图标样式优化(和发布时间列保持一致) */
:deep(.ant-table-column-title[data-column-key='author']) {
cursor: pointer;
}
:deep(.ant-table-column-title[data-column-key='author']:hover) {
color: #1890ff !important;
}
</style>
+940
View File
@@ -0,0 +1,940 @@
<template>
<div class="stats-dashboard">
<div class="dashboard-container">
<!-- 核心统计概览 - 美化版 -->
<section class="stats-overview">
<!-- 总视频数卡片 -->
<div class="stat-card primary-card main-card">
<!-- 卡片头部 -->
<div class="stat-header">
<div class="header-left">
<span class="stat-meta">视频总数</span>
<div class="stat-value">{{ totalVideos }}</div>
</div>
<div class="stat-icon video-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</div>
</div>
<!-- 细分项区域 - 新增合集短剧统计项 -->
<div class="stat-subitems">
<div class="subitem" :title="`我喜欢的视频数: ${favoriteCount}`">
<div class="subitem-icon like-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
</div>
<span class="subitem-meta">我喜欢的</span>
<span class="subitem-value">{{ favoriteCount }}</span>
</div>
<div class="subitem" :title="`我收藏的视频数: ${collectCount}`">
<div class="subitem-icon collect-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<span class="subitem-meta">我收藏的</span>
<span class="subitem-value">{{ collectCount }}</span>
</div>
<div class="subitem" :title="`我关注的视频数: ${followCount}`">
<div class="subitem-icon follow-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="8.5" cy="7" r="4"></circle>
<line x1="20" y1="8" x2="20" y2="14"></line>
<line x1="23" y1="11" x2="17" y2="11"></line>
</svg>
</div>
<span class="subitem-meta">我关注的</span>
<span class="subitem-value">{{ followCount }}</span>
</div>
<div class="subitem" :title="`图文视频数: ${graphicVideoCount}`">
<div class="subitem-icon graphic-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
<span class="subitem-meta">图文视频</span>
<span class="subitem-value">{{ graphicVideoCount }}</span>
</div>
<!-- 新增合集数量 -->
<div class="subitem" :title="`合集数量: ${mixCount}`">
<div class="subitem-icon mix-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</div>
<span class="subitem-meta">合集数量</span>
<span class="subitem-value">{{ mixCount }}</span>
</div>
<!-- 新增短剧数量 -->
<div class="subitem" :title="`短剧数量: ${seriesCount}`">
<div class="subitem-icon series-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
</div>
<span class="subitem-meta">短剧数量</span>
<span class="subitem-value">{{ seriesCount }}</span>
</div>
</div>
</div>
<!-- 总占用空间卡片 -->
<div class="stat-card secondary-card main-card">
<!-- 卡片头部 -->
<div class="stat-header">
<div class="header-left">
<span class="stat-meta">空间总计</span>
<div class="stat-value">{{ fileSizeTotal }} <span class="unit">G</span></div>
</div>
<div class="stat-icon size-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12H2v8h20v-8z" />
<path d="M6 18h.01" />
<path d="M10 18h.01" />
</svg>
</div>
</div>
<!-- 细分项区域 - 新增合集短剧空间占用 -->
<div class="stat-subitems">
<div class="subitem" :title="`喜欢的视频占用: ${favoriteSize}G`">
<div class="subitem-icon like-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
</svg>
</div>
<span class="subitem-meta">喜欢占用</span>
<span class="subitem-value">{{ favoriteSize }} <span class="unit">G</span></span>
</div>
<div class="subitem" :title="`收藏的视频占用: ${collectSize}G`">
<div class="subitem-icon collect-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<span class="subitem-meta">收藏占用</span>
<span class="subitem-value">{{ collectSize }} <span class="unit">G</span></span>
</div>
<div class="subitem" :title="`关注的视频占用: ${followSize}G`">
<div class="subitem-icon follow-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="8.5" cy="7" r="4"></circle>
<line x1="20" y1="8" x2="20" y2="14"></line>
<line x1="23" y1="11" x2="17" y2="11"></line>
</svg>
</div>
<span class="subitem-meta">关注占用</span>
<span class="subitem-value">{{ followSize }} <span class="unit">G</span></span>
</div>
<div class="subitem" :title="`图文视频占用: ${graphicVideoSize}G`">
<div class="subitem-icon graphic-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
</div>
<span class="subitem-meta">图文占用</span>
<span class="subitem-value">{{ graphicVideoSize }} <span class="unit">G</span></span>
</div>
<!-- 新增合集空间占用 -->
<div class="subitem" :title="`合集占用: ${videoMixSize}G`">
<div class="subitem-icon mix-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</div>
<span class="subitem-meta">合集占用</span>
<span class="subitem-value">{{ videoMixSize }} <span class="unit">G</span></span>
</div>
<!-- 新增短剧空间占用 -->
<div class="subitem" :title="`短剧占用: ${videoSeriesSize}G`">
<div class="subitem-icon series-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
</div>
<span class="subitem-meta">短剧占用</span>
<span class="subitem-value">{{ videoSeriesSize }} <span class="unit">G</span></span>
</div>
</div>
</div>
</section>
<!-- 详细分类统计保持不变 -->
<section class="detailed-stats">
<div class="stats-header">
<div class="tab-controls">
<a-badge :count="totalAuthors">
<button class="tab-btn" :class="{ active: currentTab === 'author' }" @click="changeTab('author')">
视频作者
</button>
</a-badge>
<a-badge :count="categoryTotal">
<button class="tab-btn" :class="{ active: currentTab === 'type' }" @click="changeTab('type')">
视频分类
</button>
</a-badge>
</div>
</div>
<transition name="stats-fade" mode="out-in">
<!-- 作者统计 -->
<div v-if="currentTab === 'author'" key="author-view" class="stats-content">
<div class="authors-grid">
<div class="author-card" v-for="(author, index) in authors" :key="index" @dblclick="handleDeleteItem(author)">
<!-- 新增横向容器包裹头像和作者信息 -->
<div class="author-info-row">
<div class="author-avatar">
<img :src="author.icon" alt="作者头像" />
</div>
<div class="author-info">
<h3 class="author-name">{{ author.name }}</h3>
<p class="author-stats">同步数量: {{ author.count }}</p>
</div>
</div>
<!-- 进度条独立在横向容器下方不与头像同行 -->
<div class="author-progress">
<div class="progress-bar" :style="{ width: `${(author.count / totalVideos) * 100}%` }"></div>
</div>
</div>
</div>
</div>
<!-- 分类统计 -->
<div v-else key="category-view" class="stats-content">
<div class="categories-grid">
<div class="category-card" v-for="(category, index) in categories" :key="index" :style="{ '--category-color': category.color }">
<div class="category-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 12 12" fill="white">
<use :xlink:href="`#${category.icon}`"></use>
</svg>
</div>
<div class="category-info">
<h3 class="category-name">{{ category.name }}</h3>
<p class="category-stats">作品数: {{ category.count }}</p>
</div>
<div class="category-percentage">
{{ Math.round((category.count / totalVideos) * 100) }}%
</div>
</div>
</div>
</div>
</transition>
</section>
</div>
<!-- SVG图标定义 -->
<svg style="display: none;">
<symbol id="cup" viewBox="0 0 12 12">
<path d="M18 4h2v16h-2zM4 4h14v2H4zM4 8h10v2H4zM4 12h10v2H4zM4 16h6v2H4zM4 20h6v2H4z" />
</symbol>
</svg>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useApiStore } from '@/store';
import { message, Spin, Empty, Tooltip, Modal, Form, FormInstance, Popconfirm } from 'ant-design-vue';
// 类型接口
interface Author {
name: string;
count: number;
icon: string;
}
interface Category {
name: string;
count: number;
color: string;
icon: string;
}
// 状态管理
const totalVideos = ref<number>(0);
const totalAuthors = ref<number>(0);
const categoryTotal = ref<number>(0);
const fileSizeTotal = ref<string>('0.00');
const totalDiskSize = ref<string>('0.00');
const favoriteCount = ref<number>(0);
const collectCount = ref<number>(0);
const followCount = ref<number>(0);
const graphicVideoCount = ref<number>(0);
// 新增:合集数量、短剧数量
const mixCount = ref<number>(0);
const seriesCount = ref<number>(0);
const favoriteSize = ref<string>('0.00');
const collectSize = ref<string>('0.00');
const followSize = ref<string>('0.00');
const graphicVideoSize = ref<string>('0.00');
// 新增:合集占用空间、短剧占用空间
const videoMixSize = ref<string>('0.00');
const videoSeriesSize = ref<string>('0.00');
const categories = ref<Category[]>([]);
const authors = ref<Author[]>([]);
const currentTab = ref<string>('author');
const tabCount = ref<number>(0);
// 组件名称
defineOptions({
name: 'StatsDashboard',
});
// 生成随机十六进制颜色的工具函数
const generateRandomColor = () => {
// 生成0-255的随机RGB值,转换为十六进制并补零
const randomHex = () =>
Math.floor(Math.random() * 256)
.toString(16)
.padStart(2, '0');
return `#${randomHex()}${randomHex()}${randomHex()}`;
};
// 加载数据和切换标签逻辑
onMounted(() => {
loadDashboardData();
});
const changeTab = (e: any) => {
currentTab.value = e;
if (e == 'author') {
tabCount.value = totalAuthors.value;
} else {
tabCount.value = categoryTotal.value;
}
};
const loadDashboardData = async () => {
try {
const res = await useApiStore().VideoStatics();
totalAuthors.value = res.data.authorCount;
categoryTotal.value = res.data.categoryCount;
totalVideos.value = res.data.videoCount;
fileSizeTotal.value = res.data.videoSizeTotal || '0.00';
totalDiskSize.value = res.data.totalDiskSize || '0.00';
favoriteCount.value = res.data.favoriteCount;
collectCount.value = res.data.collectCount;
followCount.value = res.data.followCount || 0;
graphicVideoCount.value = res.data.graphicVideoCount || 0;
// 新增:从接口获取合集、短剧数量
mixCount.value = res.data.mixCount || 0;
seriesCount.value = res.data.seriesCount || 0;
favoriteSize.value = res.data.videoFavoriteSize || '0.00';
collectSize.value = res.data.videoCollectSize || '0.00';
followSize.value = res.data.videoFollowSize || '0.00';
graphicVideoSize.value = res.data.graphicVideoSize || '0.00';
// 新增:从接口获取合集、短剧空间占用
videoMixSize.value = res.data.videoMixSize || '0.00';
videoSeriesSize.value = res.data.videoSeriesSize || '0.00';
categories.value = res.data.categories;
authors.value = res.data.authors;
// 移除categoriessss相关逻辑,直接给分类设置固定图标cup(保证显示)
// 动态为每个分类生成随机颜色,替代原有的colorArray
categories.value.forEach((item) => {
item.icon = 'cup'; // 固定使用已定义的cup图标,确保显示正常
item.color = generateRandomColor(); // 随机生成颜色
});
} catch (err) {
console.error('加载仪表盘数据失败:', err);
}
};
const getRandomElements = (arr: any[], n: number) => {
if (n <= 0) return [];
if (n >= arr.length) return [...arr];
return [...arr].sort(() => Math.random() - 0.5).slice(0, n);
};
const handleDeleteItem = (item: any) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除博主「${item.name}」所有视频吗?删除后将无法恢复。`,
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
maskClosable: false,
onOk: () => {
return new Promise((resolve, reject) => {
useApiStore()
.DeleteByAuthor(item.uperId)
.then((res) => {
if (res.code === 0) {
message.success('根据视频数量,需要时常不确定,可以稍后去日志查看...');
resolve(true);
} else {
message.error('删除博主视频失败' + (res.message || '未知错误'));
reject(false);
}
})
.catch((err) => {
console.error('删除博主视频异常', err);
message.error('删除博主视频异常' + err);
reject(false);
});
});
},
});
};
</script>
<style scoped>
/* 基础样式 */
.stats-dashboard {
min-height: 100vh;
background-color: #ffffff;
color: #333333;
font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
padding: 20px 0;
}
.dashboard-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 15px;
}
@media (max-width: 1700px) {
.dashboard-container {
max-width: 95%;
}
}
/* 核心概览区域 - 调整网格布局适配新增的2个统计项 */
.stats-overview {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 25px;
margin-bottom: 30px;
}
/* 主卡片样式 - 增强边框可见性 */
.main-card {
padding: 28px;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
/* 白天模式添加明显边框 */
border: 1px solid #e0e0e0;
}
/* 卡片hover效果 */
.main-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.12);
/* hover时边框颜色加深 */
border-color: #d0d0d0;
}
/* 卡片头部 */
.stat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 22px;
}
.header-left {
display: flex;
flex-direction: column;
gap: 6px;
}
/* 卡片元数据 */
.stat-meta {
font-size: 15px;
color: #666666;
text-transform: uppercase;
letter-spacing: 0.6px;
font-weight: 500;
}
/* 卡片主数值 */
.stat-value {
font-size: 42px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.1;
display: flex;
align-items: baseline;
gap: 8px;
}
/* 单位样式 */
.unit {
font-size: 22px;
color: #444444;
font-weight: 500;
}
/* 卡片图标 */
.stat-icon {
width: 60px;
height: 60px;
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: rgba(76, 175, 80, 0.15);
color: #4caf50;
transition: all 0.3s ease;
}
/* 空间卡片图标颜色 */
.secondary-card .stat-icon {
background-color: rgba(33, 150, 243, 0.15);
color: #2196f3;
}
/* 卡片hover时图标缩放 */
.main-card:hover .stat-icon {
transform: scale(1.08);
}
/* 细分项容器 - 增强分隔线,调整网格布局为3列适配6个统计项 */
.stat-subitems {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 从2列改为3列,适配新增的2个统计项 */
gap: 15px;
padding-top: 20px;
/* 白天模式使用明显的分隔线 */
border-top: 1px solid #d0d0d0;
}
/* 细分项样式 - 增强边框和背景 */
.subitem {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
/* 白天模式添加白色背景和明显边框 */
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 12px;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.04);
transition: all 0.2s ease;
cursor: default;
}
/* 细分项hover效果 - 增强边框和阴影 */
.subitem:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.07);
border-color: #c0c0c0;
background: #fafafa;
}
/* 细分项图标 */
.subitem-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: rgba(233, 30, 99, 0.1);
color: #e91e63;
}
/* 收藏图标颜色 */
.collect-icon {
background-color: rgba(255, 152, 0, 0.1);
color: #ff9800;
}
/* 关注图标颜色 */
.follow-icon {
background-color: rgba(156, 39, 176, 0.1);
color: #9c27b0;
}
/* 图文图标颜色 */
.graphic-icon {
background-color: rgba(255, 159, 64, 0.1);
color: #d9091a;
}
/* 新增:合集图标样式 */
.mix-icon {
background-color: rgba(63, 81, 181, 0.1);
color: #3f51b5;
}
/* 新增:短剧图标样式 */
.series-icon {
background-color: rgba(0, 188, 212, 0.1);
color: #00bcd4;
}
/* 细分项元数据 */
.subitem-meta {
font-size: 13px;
color: #666666;
font-weight: 500;
letter-spacing: 0.3px;
flex: 1;
}
/* 细分项数值 */
.subitem-value {
font-size: 16px;
font-weight: 600;
color: #222222;
display: flex;
align-items: baseline;
gap: 4px;
}
/* 细分项单位 */
.subitem-value .unit {
font-size: 12px;
color: #555555;
font-weight: 500;
}
/* 卡片顶部主题边框 */
.primary-card {
border-top: 4px solid #4caf50;
}
.secondary-card {
border-top: 4px solid #2196f3;
}
/* 详细分类统计区域 */
.detailed-stats {
background: #f5f5f5;
border-radius: 12px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.tab-controls {
display: flex;
gap: 10px;
}
.tab-btn {
background: transparent;
border: none;
color: #666666;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn.active {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
font-weight: 500;
}
.stats-content {
animation: fadeIn 0.5s ease;
}
.authors-grid,
.categories-grid {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
}
@media (min-width: 576px) {
.authors-grid,
.categories-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 992px) {
.authors-grid,
.categories-grid {
grid-template-columns: repeat(5, 1fr);
}
}
/* 作者卡片样式 - 核心修改:头像+文字横向,进度条独立在下 */
.author-card {
display: flex;
flex-direction: column; /* 整体纵向布局,容纳「头像+文字行」和「进度条行」 */
gap: 10px; /* 头像文字行 与 进度条 之间的间距,可微调 */
padding: 15px;
background: #eeeeee;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}
.author-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12);
}
/* 头像+文字 横向容器 */
.author-info-row {
display: flex;
align-items: center; /* 头像与文字垂直居中 */
gap: 12px; /* 头像与文字的间距,减少空白 */
}
.author-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0; /* 防止头像被压缩 */
}
.author-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 作者文字信息(名字+作品数) */
.author-info {
flex: 1; /* 占据头像右侧剩余空间 */
display: flex;
flex-direction: column; /* 名字在上,作品数在下,紧凑排列 */
gap: 3px; /* 名字与作品数的间距,减少空白 */
}
.author-name,
.category-name {
margin: 0; /* 移除默认外边距,消除多余空白 */
font-size: 16px;
color: #333333;
}
.author-stats,
.category-stats {
margin: 5px 0px; /* 移除默认外边距 */
font-size: 12px;
color: #666666;
line-height: 1; /* 紧凑行高,减少垂直空白 */
}
/* 进度条 - 独立成行,不与头像同行 */
.author-progress {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
width: 100%; /* 占满作者卡片宽度 */
}
.progress-bar {
height: 100%;
background: #4caf50;
border-radius: 3px;
transition: width 0.5s ease;
}
/* 分类卡片样式 */
.category-card {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
background: #eeeeee;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}
.category-card:hover {
transform: translateY(-3px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12);
}
.category-icon {
width: 40px;
height: 40px;
border-radius: 8px;
background-color: var(--category-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.category-percentage {
font-size: 14px;
font-weight: 500;
color: var(--category-color);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stats-fade-enter-from,
.stats-fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
.stats-fade-enter-active,
.stats-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
/* 夜间模式样式 - 保持原有效果 */
html.dark-mode .main-card {
border-color: rgba(255, 255, 255, 0.1);
background-color: rgba(30, 30, 50, 0.9);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
}
html.dark-mode .main-card:hover {
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.25);
}
html.dark-mode .stat-subitems {
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
html.dark-mode .subitem {
background: rgba(40, 40, 65, 0.7);
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
}
html.dark-mode .subitem:hover {
background: rgba(40, 40, 65, 0.9);
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}
/* 夜间模式其他样式保持不变 */
html.dark-mode .stats-dashboard {
background-color: #1a1a2e;
color: #eaeaea;
}
html.dark-mode .stat-meta {
color: #b0b0c3;
}
html.dark-mode .stat-value {
color: #ffffff;
}
html.dark-mode .unit {
color: #d0d0d0;
}
html.dark-mode .stat-icon {
background-color: rgba(76, 175, 80, 0.25);
}
html.dark-mode .secondary-card .stat-icon {
background-color: rgba(33, 150, 243, 0.25);
}
html.dark-mode .subitem-meta {
color: #c0c0d3;
}
html.dark-mode .subitem-value {
color: #ffffff;
}
html.dark-mode .subitem-value .unit {
color: #b0b0c3;
}
html.dark-mode .subitem-icon {
background-color: rgba(233, 30, 99, 0.2);
}
html.dark-mode .collect-icon {
background-color: rgba(255, 152, 0, 0.2);
}
html.dark-mode .follow-icon {
background-color: rgba(156, 39, 176, 0.2);
}
html.dark-mode .graphic-icon {
background-color: rgba(255, 159, 64, 0.2);
}
/* 新增:夜间模式下合集、短剧图标样式 */
html.dark-mode .mix-icon {
background-color: rgba(63, 81, 181, 0.2);
}
html.dark-mode .series-icon {
background-color: rgba(0, 188, 212, 0.2);
}
html.dark-mode .detailed-stats {
background: rgba(30, 30, 50, 0.8);
}
html.dark-mode .author-card,
html.dark-mode .category-card {
background: rgba(40, 40, 65, 0.6);
box-shadow: none;
}
html.dark-mode .author-name,
html.dark-mode .category-name {
color: #ffffff;
}
</style>
File diff suppressed because it is too large Load Diff
+28
View File
@@ -95,6 +95,7 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
//视频统计
async function VideoStatics() {
return http.request<any, Response<any>>('/api/video/statics', 'get').then(r => {
return r;
@@ -102,6 +103,14 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
//视频曲线
async function VideoChart() {
return http.request<any, Response<any>>('/api/video/chart', 'get').then(r => {
return r;
}).finally(() => {
});
}
//视频查询
async function VideoPageList(param: object) {
@@ -337,6 +346,22 @@ export const useApiStore = defineStore('coreapi', () => {
});
}
//合集、自定义收藏夹、短剧列表
async function CatePageList(param: object) {
return http.request<any, Response<any>>('/api/cate/paged', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
//批量修改 合集、自定义收藏夹、短剧
async function BatchSaveCate(param: object) {
return http.request<any, Response<any>>('/api/cate/BatchSave', 'post_json', param).then(r => {
return r;
}).finally(() => {
});
}
// // 音频文件上传接口
// async function apiUploadAudio(formData: FormData, options?: { onUploadProgress?: (progressEvent: ProgressEvent) => void }) {
@@ -362,6 +387,9 @@ export const useApiStore = defineStore('coreapi', () => {
// }
return {
VideoChart,
BatchSaveCate,
CatePageList,
getVer,
mp3List,
BathRealDelete,
Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

+6
View File
@@ -106,6 +106,12 @@
<HintPath>lib\dy.sync.lib.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Content Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
<!--<Exec WorkingDirectory="$(SpaRoot)" Command="yarn install" />
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<ActiveDebugProfile>tiny.ddns</ActiveDebugProfile>
<NameOfLastUsedPublishProfile>E:\gitea\dysync\Properties\PublishProfiles\docker.pubxml</NameOfLastUsedPublishProfile>
<ActiveDebugProfile>dy.net</ActiveDebugProfile>
<NameOfLastUsedPublishProfile>E:\code\dysync\Properties\PublishProfiles\docker.pubxml</NameOfLastUsedPublishProfile>
<Controller_SelectedScaffolderID>ApiControllerEmptyScaffolder</Controller_SelectedScaffolderID>
<Controller_SelectedScaffolderCategoryPath>root/Common/Api</Controller_SelectedScaffolderCategoryPath>
</PropertyGroup>
+1
View File
@@ -220,6 +220,7 @@ namespace dy.net.extension
services.AddHttpClient(DouyinRequestParamManager.DY_HTTP_CLIENT, client =>
{
client.DefaultRequestHeaders.Add("User-Agent", DouyinRequestParamManager.DY_USER_AGENT);
client.BaseAddress = new Uri("https://www.douyin.com");
}).ConfigurePrimaryHttpMessageHandler(ignoreSslHandlerFactory);
// 抖音下载客户端(单独配置,保持原有特性)
+91 -89
View File
@@ -5,105 +5,107 @@ using Serilog;
using static Quartz.Logging.OperationName;
namespace dy.net.job
{ /// <summary>
/// 抖音任务依赖监听器(独立公共类)
/// 作用:监听任务执行完成事件,触发下一个依赖任务,实现顺序执行
/// </summary>
public class DouyinJobDependencyListener : IJobListener
{
// 监听器名称(唯一标识,不可重复)
public string Name => "DouyinJobDependencyListener";
{
// /// <summary>
///// 抖音任务依赖监听器(独立公共类)
///// 作用:监听任务执行完成事件,触发下一个依赖任务,实现顺序执行
///// </summary>
// public class DouyinJobDependencyListener : IJobListener
// {
// // 监听器名称(唯一标识,不可重复)
// public string Name => "DouyinJobDependencyListener";
/// <summary>
/// 任务配置字典(从外部注入)
/// </summary>
private readonly Dictionary<string, JobConfig> _jobConfigs;
// /// <summary>
// /// 任务配置字典(从外部注入)
// /// </summary>
// private readonly Dictionary<string, JobConfig> _jobConfigs;
/// <summary>
/// 任务依赖关系(从外部注入,定义执行顺序)
/// </summary>
private readonly Dictionary<string, string> _jobDependency;
// /// <summary>
// /// 任务依赖关系(从外部注入,定义执行顺序)
// /// </summary>
// private readonly Dictionary<string, string> _jobDependency;
/// <summary>
/// 任务服务(用于触发下一个任务,从外部注入)
/// </summary>
private readonly DouyinQuartzJobService _jobService;
// /// <summary>
// /// 任务服务(用于触发下一个任务,从外部注入)
// /// </summary>
// private readonly DouyinQuartzJobService _jobService;
/// <summary>
/// 构造函数(依赖注入)
/// </summary>
/// <param name="jobConfigs">任务配置</param>
/// <param name="jobDependency">任务依赖关系</param>
/// <param name="jobService">任务服务</param>
public DouyinJobDependencyListener(
Dictionary<string, JobConfig> jobConfigs,
Dictionary<string, string> jobDependency,
DouyinQuartzJobService jobService)
{
_jobConfigs = jobConfigs ?? throw new ArgumentNullException(nameof(jobConfigs), "任务配置不能为空");
_jobDependency = jobDependency ?? throw new ArgumentNullException(nameof(jobDependency), "任务依赖关系不能为空");
_jobService = jobService ?? throw new ArgumentNullException(nameof(jobService), "任务服务不能为空");
}
// /// <summary>
// /// 构造函数(依赖注入)
// /// </summary>
// /// <param name="jobConfigs">任务配置</param>
// /// <param name="jobDependency">任务依赖关系</param>
// /// <param name="jobService">任务服务</param>
// public DouyinJobDependencyListener(
// Dictionary<string, JobConfig> jobConfigs,
// Dictionary<string, string> jobDependency,
// DouyinQuartzJobService jobService)
// {
// _jobConfigs = jobConfigs ?? throw new ArgumentNullException(nameof(jobConfigs), "任务配置不能为空");
// _jobDependency = jobDependency ?? throw new ArgumentNullException(nameof(jobDependency), "任务依赖关系不能为空");
// _jobService = jobService ?? throw new ArgumentNullException(nameof(jobService), "任务服务不能为空");
// }
/// <summary>
/// 任务执行前触发(无需处理)
/// </summary>
public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
// /// <summary>
// /// 任务执行前触发(无需处理)
// /// </summary>
// public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default)
// {
// return Task.CompletedTask;
// }
/// <summary>
/// 任务被否决执行时触发(无需处理)
/// </summary>
public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
// /// <summary>
// /// 任务被否决执行时触发(无需处理)
// /// </summary>
// public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default)
// {
// return Task.CompletedTask;
// }
/// <summary>
/// 任务执行完成后触发(核心逻辑:触发下一个依赖任务)
/// </summary>
public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default)
{
var currentJobKey = context.JobDetail.Key;
Log.Information("【任务监听】任务执行完成 - 任务名称: {JobName}, 执行状态: {Status}",
currentJobKey.Name, jobException == null ? "成功" : "失败");
// /// <summary>
// /// 任务执行完成后触发(核心逻辑:触发下一个依赖任务)
// /// </summary>
// public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default)
// {
// var currentJobKey = context.JobDetail.Key;
// Log.Information("【任务监听】任务执行完成 - 任务名称: {JobName}, 执行状态: {Status}",
// currentJobKey.Name, jobException == null ? "成功" : "失败");
// 1. 若当前任务执行失败,终止后续依赖任务(避免无效执行)
if (jobException != null)
{
Log.Error(jobException, "【任务监听】任务 {JobName} 执行失败,终止后续任务链条", currentJobKey.Name);
return;
}
// // 1. 若当前任务执行失败,终止后续依赖任务(避免无效执行)
// if (jobException != null)
// {
// Log.Error(jobException, "【任务监听】任务 {JobName} 执行失败,终止后续任务链条", currentJobKey.Name);
// return;
// }
// 2. 根据当前任务的 JobKey,找到对应的配置 Key(如:dy.job.key.collect → collect
var currentConfigKey = _jobConfigs.FirstOrDefault(kv => kv.Value.JobKey == currentJobKey.Name).Key;
if (string.IsNullOrEmpty(currentConfigKey))
{
Log.Warning("【任务监听】未找到任务 {JobName} 的配置信息,任务链条终止", currentJobKey.Name);
return;
}
// // 2. 根据当前任务的 JobKey,找到对应的配置 Key(如:dy.job.key.collect → collect
// var currentConfigKey = _jobConfigs.FirstOrDefault(kv => kv.Value.JobKey == currentJobKey.Name).Key;
// if (string.IsNullOrEmpty(currentConfigKey))
// {
// Log.Warning("【任务监听】未找到任务 {JobName} 的配置信息,任务链条终止", currentJobKey.Name);
// return;
// }
// 3. 查找下一个依赖任务的配置 Key
if (!_jobDependency.TryGetValue(currentConfigKey, out var nextConfigKey) || string.IsNullOrEmpty(nextConfigKey))
{
Log.Information("【任务监听】任务 {JobName} 是最后一个任务,本次任务链条执行完毕", currentJobKey.Name);
return;
}
// // 3. 查找下一个依赖任务的配置 Key
// if (!_jobDependency.TryGetValue(currentConfigKey, out var nextConfigKey) || string.IsNullOrEmpty(nextConfigKey))
// {
// Log.Information("【任务监听】任务 {JobName} 是最后一个任务,本次任务链条执行完毕", currentJobKey.Name);
// return;
// }
// 4. 触发下一个任务(标记为「依赖触发」,立即执行)
Log.Information("【任务监听】准备触发下一个任务: {NextJobName}(依赖触发)", nextConfigKey);
var triggerSuccess = await _jobService.StartJobAsync(nextConfigKey, "", isDependencyTrigger: true);
// // 4. 触发下一个任务(标记为「依赖触发」,立即执行)
// Log.Information("【任务监听】准备触发下一个任务: {NextJobName}(依赖触发)", nextConfigKey);
// var triggerSuccess = await _jobService.StartJobAsync(nextConfigKey, "", isDependencyTrigger: true);
if (triggerSuccess)
{
Log.Information("【任务监听】下一个任务 {NextJobName} 触发成功", nextConfigKey);
}
else
{
Log.Error("【任务监听】下一个任务 {NextJobName} 触发失败,任务链条中断", nextConfigKey);
}
}
}
// if (triggerSuccess)
// {
// Log.Information("【任务监听】下一个任务 {NextJobName} 触发成功", nextConfigKey);
// }
// else
// {
// Log.Error("【任务监听】下一个任务 {NextJobName} 触发失败,任务链条中断", nextConfigKey);
// }
// }
// }
}
+20 -14
View File
@@ -3,6 +3,7 @@ using dy.net.model.entity;
using dy.net.model.response;
using dy.net.service;
using dy.net.utils;
using Serilog;
using System;
namespace dy.net.job
@@ -25,6 +26,11 @@ namespace dy.net.job
}
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.SavePath, "author");
}
protected override async Task<DouyinVideoInfoResponse> FetchVideoData(DouyinCookie cookie, string cursor, DouyinFollowed followed, DouyinCollectCate cate)
{
return await douyinHttpClientService.SyncCollectVideos(cursor, count, cookie.Cookies);
@@ -35,27 +41,27 @@ namespace dy.net.job
return data != null && data.HasMore == 1 && cookie.CollHasSyncd == 0;
}
protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount,DouyinFollowed followed)
protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount,DouyinFollowed followed,DouyinCollectCate cate)
{
if (syncCount > 0)
{
Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],本次共同步成功{syncCount}条视频");
cookie.CollHasSyncd = 1;
await douyinCookieService.UpdateAsync(cookie);
}
else
{
Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],没有可以同步的新视频");
}
cookie.CollHasSyncd = 1;
await douyinCookieService.UpdateAsync(cookie);
Log.Debug($"[{VideoType}]-[{cookie.UserName}],本次成功同步{syncCount}条视频");
}
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
var (tag1, _, _) = GetVideoTags(item);
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizeLinuxFileName(tag1, "", true);
var folder = Path.Combine(cookie.SavePath, safeTag1, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true)}");
string authorFolder;
if (string.IsNullOrWhiteSpace(item.Author?.Nickname) && string.IsNullOrWhiteSpace(item.Author?.Uid))
{
authorFolder = "未知博主";
}
else
{
authorFolder = $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Author?.Nickname, item.Author?.Uid, true)}";
}
var folder = Path.Combine(cookie.SavePath, authorFolder, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true)}");
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder;
+19 -14
View File
@@ -15,6 +15,11 @@ namespace dy.net.job
protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_favorite;
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.FavSavePath, "author");
}
protected override async Task<List<DouyinCookie>> GetSyncCookies()
{
return await douyinCookieService.GetOpendCookiesAsync(x=> !string.IsNullOrWhiteSpace(x.FavSavePath)&&!string.IsNullOrWhiteSpace(x.SecUserId));
@@ -31,25 +36,25 @@ namespace dy.net.job
}
protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount, DouyinFollowed followed)
protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount, DouyinFollowed followed,DouyinCollectCate cate)
{
if (syncCount > 0)
{
Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],本次共同步成功{syncCount}条视频");
cookie.FavHasSyncd = 1;
await douyinCookieService.UpdateAsync(cookie);
}
else
{
Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],没有可以同步的新视频");
}
cookie.FavHasSyncd = 1;
await douyinCookieService.UpdateAsync(cookie);
await base.HandleSyncCompletion(cookie, syncCount, followed, cate);
}
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed,DouyinCollectCate cate)
{
var (tag1, _, _) = GetVideoTags(item);
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizeLinuxFileName(tag1,"",true);
var folder = Path.Combine(cookie.FavSavePath, safeTag1, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId,true)}");
string authorFolder;
if (string.IsNullOrWhiteSpace(item.Author?.Nickname) && string.IsNullOrWhiteSpace(item.Author?.Uid))
{
authorFolder = "未知博主";
}
else
{
authorFolder = $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Author?.Nickname, item.Author?.Uid, true)}";
}
var folder = Path.Combine(cookie.FavSavePath, authorFolder, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true)}");
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder;
}
+269 -220
View File
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -15,14 +15,32 @@ namespace dy.net.job
protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_custom_collect;
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (cate != null)
{
var folder = Path.Combine(cookie.SavePath, DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, "", true), DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true));
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder;
}
else
{
return base.CreateSaveFolder(cookie, item, config,followed,cate);
}
}
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.SavePath, "author");
}
protected override async Task<DouyinVideoInfoResponse> FetchVideoData(DouyinCookie cookie, string cursor, DouyinFollowed followed, DouyinCollectCate cate)
{
return await douyinHttpClientService.SyncCollectVideos(cursor, count, cookie.Cookies);
return await douyinHttpClientService.SyncCollectVideosByCollectId(cursor,count,cookie.Cookies,cate.XId);
}
protected override bool ShouldContinueSync(DouyinCookie cookie, DouyinVideoInfoResponse data, DouyinFollowed followed = null)
{
return data != null && data.HasMore == 1 && cookie.CollHasSyncd == 0;
return data != null && data.HasMore == 1;
}
}
@@ -11,9 +11,9 @@ using System.Threading.Tasks;
namespace dy.net.job
{
public class DouyinFollowedViedoSyncJob : DouyinBasicSyncJob
public class DouyinFollowedSyncJob : DouyinBasicSyncJob
{
public DouyinFollowedViedoSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService)
public DouyinFollowedSyncJob(DouyinCookieService douyinCookieService, DouyinHttpClientService douyinHttpClientService, DouyinVideoService douyinVideoService, DouyinCommonService douyinCommonService, DouyinFollowService douyinFollowService, DouyinMergeVideoService douyinMergeVideoService, DouyinCollectCateService douyinCollectCateService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService, douyinCollectCateService)
{
}
@@ -33,7 +33,10 @@ namespace dy.net.job
{
return data != null && data.HasMore == 1 && cookie.UperSyncd == 0 && followed.FullSync;
}
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.UpSavePath, "author");
}
/// <summary>
/// 关注用户特殊处理文件夹存储路径,用户可自定义保存路径
@@ -41,6 +44,7 @@ namespace dy.net.job
/// <param name="cookie"></param>
/// <param name="item"></param>
/// <param name="followed"></param>
/// <param name="cate"></param>
/// <param name="config"></param>
/// <returns></returns>
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed,DouyinCollectCate cate)
@@ -49,7 +53,7 @@ namespace dy.net.job
// 1. 优先获取有效的作者名称(遵循原有优先级:followed.UperName > item.Author.Nickname > 默认值)
var rawAuthorName = followed?.UperName ?? item?.Author?.Nickname;
var authorName = string.IsNullOrWhiteSpace(rawAuthorName)
? "UnknownAuthor"
? "未知博主"
: DouyinFileNameHelper.SanitizeLinuxFileName(rawAuthorName, "", true);
// 2. 确定最终文件夹路径(遵循原有优先级:followed.SavePath > authorName > 基础路径)
var targetFolderName = !string.IsNullOrWhiteSpace(followed?.SavePath) ? followed.SavePath : authorName;
@@ -58,17 +62,10 @@ namespace dy.net.job
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
#endregion
if (config.UperSaveTogether)
{
return folder;
}
else
{
var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId,true);
var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result;
var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName;
return Path.Combine(folder, fileNameFolder);
}
var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true);
var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName);
var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName;
return Path.Combine(folder, fileNameFolder);
}
/// <summary>
/// 关注的视频,生成文件名称
@@ -76,8 +73,9 @@ namespace dy.net.job
/// <param name="cookie"></param>
/// <param name="item"></param>
/// <param name="config"></param>
/// <param name="cate"></param>
/// <returns></returns>
protected override string GetVideoFileName(DouyinCookie cookie, Aweme item,AppConfig config)
protected override string GetVideoFileName(DouyinCookie cookie, Aweme item,AppConfig config,DouyinCollectCate cate)
{
string Format = "mp4";
@@ -109,7 +107,7 @@ namespace dy.net.job
if (config?.UperUseViedoTitle ?? false)//优先
{
var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId);
var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result;
var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName);
fileName = string.IsNullOrWhiteSpace(existingName) ? $"{sampleName}.{Format}" : $"{existingName}.{Format}";
}
else
@@ -146,47 +144,20 @@ namespace dy.net.job
/// <param name="item"></param>
/// <param name="config"></param>
/// <param name="imageType"></param>
/// <param name="cate"></param>
/// <returns></returns>
protected override string GetNfoFileName(DouyinCookie cookie, Aweme item, AppConfig config, string imageType)
protected override string GetNfoFileName(DouyinCookie cookie, Aweme item, AppConfig config, string imageType,DouyinCollectCate cate)
{
if (config.UperSaveTogether)
{
var videoFileName = GetVideoFileName(cookie, item,config);
return $"{Path.GetFileNameWithoutExtension(videoFileName)}{imageType}";
}
else
{
return base.GetNfoFileName(cookie, item, config, imageType);
}
return base.GetNfoFileName(cookie, item, config, imageType, cate);
}
protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount, DouyinFollowed followed)
protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount, DouyinFollowed followed, DouyinCollectCate cate)
{
if (syncCount > 0)
{
Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],本次共同步成功{syncCount}条视频");
cookie.UperSyncd = 1;
await douyinCookieService.UpdateAsync(cookie);
}
else
{
Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}]-{(followed == null ? "" : $"{followed.UperName}")},没有可以同步的新视频");
}
cookie.UperSyncd = 1;
await douyinCookieService.UpdateAsync(cookie);
await base.HandleSyncCompletion(cookie, syncCount, followed, cate);
}
protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item)
{
var config = douyinCommonService.GetConfig();
string simplifiedTitle = string.Empty;
if (config?.UperUseViedoTitle ?? false)
{
simplifiedTitle = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId);
}
return new VideoEntityDifferences
{
VideoTitleSimplify = simplifiedTitle
};
}
}
}
+131 -216
View File
@@ -3,45 +3,25 @@ using dy.net.model.entity;
using dy.net.model.response;
using dy.net.service;
using Quartz;
using Serilog;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace dy.net.job
{
[DisallowConcurrentExecution] // 禁止并发执行,确保同一时间只有一个实例在运行
[DisallowConcurrentExecution]
public class DouyinFollowsAndCollnectsSyncJob : IJob
{
// 服务依赖(统一下划线命名)
private readonly DouyinCollectCateService _douyinCollectCateService;
private readonly DouyinCookieService _dyCookieService;
private readonly DouyinHttpClientService _douyinService;
private readonly DouyinFollowService _followService;
private readonly DouyinCommonService _douyinCommonService;
/// <summary>
/// 抖音收藏夹服务
/// </summary>
protected readonly DouyinCollectCateService douyinCollectCateService;
/// <summary>
/// 抖音Cookie服务,用于获取和管理用户Cookie
/// </summary>
protected readonly DouyinCookieService _dyCookieService;
/// <summary>
/// 抖音HTTP客户端服务,用于发送HTTP请求
/// </summary>
protected readonly DouyinHttpClientService _douyinService;
/// <summary>
/// 抖音关注服务,用于管理关注数据
/// </summary>
protected readonly DouyinFollowService _followService;
protected readonly DouyinCommonService douyinCommonService;
public DouyinFollowsAndCollnectsSyncJob(DouyinCookieService dyCookieService, DouyinHttpClientService douyinService, DouyinFollowService followService, DouyinCommonService douyinCommonService, DouyinCollectCateService douyinCollectCateService)
{
_dyCookieService = dyCookieService;
_douyinService = douyinService;
_followService = followService;
this.douyinCommonService = douyinCommonService;
this.douyinCollectCateService = douyinCollectCateService;
}
// 第一步:定义常量和枚举(建议放在单独的常量类中,此处为内联演示)
// 常量定义
private const string DEFAULT_FOLLOW_COUNT = "20";
private const int INVALID_COOKIE_STATUS_CODE = 8;
private const string LOG_TAG_COLLECT = "收藏夹同步";
@@ -49,299 +29,234 @@ namespace dy.net.job
private const string LOG_TAG_SERIES = "短剧列表同步";
private const string LOG_TAG_FOLLOW = "关注列表同步";
/// <summary>
/// 抖音数据同步任务执行方法(优化版)
/// </summary>
/// <param name="context">任务执行上下文</param>
// 构造函数注入
public DouyinFollowsAndCollnectsSyncJob(
DouyinCookieService dyCookieService,
DouyinHttpClientService douyinService,
DouyinFollowService followService,
DouyinCommonService douyinCommonService,
DouyinCollectCateService douyinCollectCateService)
{
_dyCookieService = dyCookieService;
_douyinService = douyinService;
_followService = followService;
_douyinCommonService = douyinCommonService;
_douyinCollectCateService = douyinCollectCateService;
}
public async Task Execute(IJobExecutionContext context)
{
// 1. 获取有效Cookie列表,提前做空值判断
var cookies = await _dyCookieService.GetOpendCookiesAsync();
if (cookies == null || !cookies.Any())
{
Serilog.Log.Debug("当前无可用的抖音Cookie,同步任务跳过");
Log.Debug("当前无可用的抖音Cookie,同步任务跳过");
return;
}
// 2. 获取应用配置
AppConfig conf = douyinCommonService.GetConfig();
var conf = _douyinCommonService.GetConfig();
// 3. 遍历每个Cookie,执行各类数据同步
foreach (var ck in cookies)
{
Serilog.Log.Information($"开始处理Cookie用户:[{ck.UserName}]ID{ck.Id}");
Log.Information($"开始处理Cookie用户:[{ck.UserName}]ID{ck.Id}");
// 3.1 同步自定义收藏夹(UseCollectFolder=true时
// 调用通用方法,传入差异化参数(无委托,参数直观
if (ck.UseCollectFolder)
{
await SyncCollectDataAsync(
cookie: ck,
cateType: VideoTypeEnum.dy_custom_collect,
dataFetchFunc: async (offset) => await _douyinService.SyncCollectFolderList(ck.Cookies, offset),
entityConvertFunc: (collectItem) => new DouyinCollectCate
await SyncCollectGenericAsync(ck, VideoTypeEnum.dy_custom_collect,
(cookie, offset) => _douyinService.SyncCollectFolderList(cookie.Cookies, offset),
item => new DouyinCollectCate
{
CookieId = ck.Id,
CateType = VideoTypeEnum.dy_custom_collect,
Name = collectItem.CollectsName,
Name = item.CollectsName,
Sync = false,
XId = collectItem.CollectsId
XId = item.CollectsId,
Total = item.TotalNumber
},
hasMoreFunc: (data) => data?.HasMore ?? false,
cursorFunc: (data) => data?.Cursor.ToString() ?? "0",
dataListFunc: (data) => data?.CollectsList,
logTag: LOG_TAG_COLLECT
);
data => data?.HasMore ?? false,
data => data?.Cursor.ToString() ?? "0",
data => data?.CollectsList,
LOG_TAG_COLLECT);
}
// 3.2 同步收藏夹合集(DownMix=true时)
if (ck.DownMix)
{
await SyncCollectDataAsync(
cookie: ck,
cateType: VideoTypeEnum.dy_mix,
dataFetchFunc: async (offset) => await _douyinService.SyncMixList(ck.Cookies, offset),
entityConvertFunc: (mixItem) => new DouyinCollectCate
await SyncCollectGenericAsync(ck, VideoTypeEnum.dy_mix,
(cookie, offset) => _douyinService.SyncMixList(cookie.Cookies, offset),
item => new DouyinCollectCate
{
CookieId = ck.Id,
CateType = VideoTypeEnum.dy_mix,
Name = mixItem.MixName,
Name = item.MixName,
Sync = false,
XId = mixItem.MixId,
CoverUrl = mixItem.CoverUrl?.UrlList?.FirstOrDefault() // 空值防护:避免CoverUrl为null
XId = item.MixId,
Total = item?.Statis?.UpdatedToEpisode ?? 0,
CoverUrl = item.CoverUrl?.UrlList?.LastOrDefault()
},
hasMoreFunc: (data) => (data?.HasMore ?? 0) == 1,
cursorFunc: (data) => data?.Cursor.ToString() ?? "0",
dataListFunc: (data) => data?.MixInfos,
logTag: LOG_TAG_MIX
);
data => (data?.HasMore ?? 0) == 1,
data => data?.Cursor.ToString() ?? "0",
data => data?.MixInfos,
LOG_TAG_MIX);
}
// 3.3 同步收藏夹短剧(DownSeries=true时)
if (ck.DownSeries)
{
await SyncCollectDataAsync(
cookie: ck,
cateType: VideoTypeEnum.dy_series,
dataFetchFunc: async (offset) => await _douyinService.SyncSeriesList(ck.Cookies, offset),
entityConvertFunc: (seriesItem) => new DouyinCollectCate
await SyncCollectGenericAsync(ck, VideoTypeEnum.dy_series,
(cookie, offset) => _douyinService.SyncSeriesList(cookie.Cookies, offset),
item => new DouyinCollectCate
{
CookieId = ck.Id,
CateType = VideoTypeEnum.dy_series,
Name = seriesItem.SeriesName,
Name = item.SeriesName,
Sync = false,
XId = seriesItem.SeriesId,
CoverUrl = seriesItem.CoverImage?.ImageUrlList?.FirstOrDefault() // 空值防护:避免CoverImage为null
XId = item.SeriesId,
Total = item?.Stats?.TotalEpisodeCount ?? 0,
CoverUrl = item.CoverImage?.ImageUrlList?.FirstOrDefault()
},
hasMoreFunc: (data) => (data?.HasMore ?? 0) == 1,
cursorFunc: (data) => data?.Cursor.ToString() ?? "0",
dataListFunc: (data) => data?.SeriesList,
logTag: LOG_TAG_SERIES
);
data => (data?.HasMore ?? 0) == 1,
data => data?.Cursor.ToString() ?? "0",
data => data?.SeriesList,
LOG_TAG_SERIES);
}
// 3.4 同步关注列表(单独处理,逻辑特殊不纳入通用方法)
await SyncFollowListAsync(ck, conf);
Serilog.Log.Debug($"完成Cookie用户:[{ck.UserName}]ID{ck.Id})的所有收藏夹信息、合集信息、关注列表信息、短剧信息同步");
Log.Debug($"完成[{ck.UserName}] [列表]同步, 包括 [自定义收藏夹、关注、合集、短剧] ");
}
// 4. 标记首次运行完成(仅执行一次)
await douyinCommonService.SetConfigNotFirstRunning();
await _douyinCommonService.SetConfigNotFirstRunning();
}
#region
#region
/// <summary>
/// 通用收藏类数据同步方法(泛型封装,消除冗余
/// 通用收藏类数据同步方法(无复杂委托,参数直观,便于调试
/// </summary>
/// <typeparam name="TData">抖音返回的分页数据类型</typeparam>
/// <typeparam name="TItem">抖音返回的列表项类型</typeparam>
/// <param name="cookie">当前Cookie信息</param>
/// <param name="cateType">分类类型</param>
/// <param name="dataFetchFunc">分页数据获取委托</param>
/// <param name="entityConvertFunc">抖音项转换为DouyinCollectCate的委托</param>
/// <param name="hasMoreFunc">判断是否还有更多数据的委托</param>
/// <param name="cursorFunc">获取下一页游标值的委托</param>
/// <param name="dataListFunc">从分页数据中提取列表的委托</param>
/// <param name="logTag">日志标签</param>
private async Task SyncCollectDataAsync<TData, TItem>(
/// <typeparam name="TData">分页数据类型</typeparam>
/// <typeparam name="TItem">列表项类型</typeparam>
private async Task SyncCollectGenericAsync<TData, TItem>(
DouyinCookie cookie,
VideoTypeEnum cateType,
Func<string, Task<TData>> dataFetchFunc,
// 数据获取方法(最简委托,仅传必要参数)
Func<DouyinCookie, string, Task<TData>> dataFetchFunc,
// 实体转换方法(内联lambda,调试可直接看到转换逻辑)
Func<TItem, DouyinCollectCate> entityConvertFunc,
// 分页判断方法
Func<TData, bool> hasMoreFunc,
Func<TData, string> cursorFunc,
Func<TData, List<TItem>> dataListFunc,
// 游标获取方法
Func<TData, string> getCursorFunc,
// 列表提取方法
Func<TData, List<TItem>> getDataListFunc,
string logTag)
{
var collectDataList = new List<DouyinCollectCate>();
string offset = "0";
bool hasMore = true;
List<DouyinCollectCate> collectDataList = new List<DouyinCollectCate>();
try
{
// 1. 分页获取抖音数据
while (hasMore)
{
var pageData = await dataFetchFunc(offset);
if (pageData == null)
{
//Serilog.Log.Warning($"[{cookie.UserName}] - {logTag}:获取分页数据为空,停止分页");
break;
}
// 1. 获取分页数据(直接调用,调试可断点到具体Service方法)
var pageData = await dataFetchFunc(cookie, offset);
if (pageData == null) break;
// 2. 更新分页状态
// 2. 更新分页状态(逻辑透明)
hasMore = hasMoreFunc(pageData);
offset = cursorFunc(pageData);
offset = getCursorFunc(pageData);
// 3. 提取当前页列表数据并转换
var currentPageItems = dataListFunc(pageData);
if (currentPageItems != null && currentPageItems.Any())
// 3. 提取并转换数据(内联逻辑,调试可看每一项转换结果)
var currentItems = getDataListFunc(pageData);
if (currentItems?.Any() == true)
{
var convertItems = currentPageItems.Select(entityConvertFunc).ToList();
collectDataList.AddRange(convertItems);
Serilog.Log.Debug($"[{cookie.UserName}] - {logTag}:获取到{currentPageItems.Count}条数据,累计{collectDataList.Count}条");
collectDataList.AddRange(currentItems.Select(entityConvertFunc));
Log.Debug($"[{cookie.UserName}] - {logTag}:获取{currentItems.Count}条,累计{collectDataList.Count}条");
}
}
// 4. 调用Sync方法同步到数据库
// 4. 同步到数据库
if (collectDataList.Any())
{
var (add, update, delete, succ) = await douyinCollectCateService.Sync(
collectDataList,
cookie.Id,
cateType);
Serilog.Log.Debug($"[{cookie.UserName}] - {logTag}:同步完成,新增{add}条,更新{update}条,删除{delete}条,是否成功:{succ}");
var (add, update, delete, succ) = await _douyinCollectCateService.Sync(collectDataList, cookie.Id, cateType);
Log.Debug($"[{cookie.UserName}] - {logTag}:同步完成 新增:{add} 更新:{update} 删除:{delete} 成功:{succ}");
}
else
{
Serilog.Log.Debug($"[{cookie.UserName}] - {logTag}:无有效数据需要同步");
Log.Debug($"[{cookie.UserName}] - {logTag}:无有效数据");
}
}
catch (Exception ex)
{
Serilog.Log.Error(ex, $"[{cookie.UserName}] - {logTag}分页同步失败,异常信息:{ex.Message}");
Log.Error(ex, $"[{cookie.UserName}] - {logTag}:同步失败");
}
}
#endregion
/// <summary>
/// 关注列表单独同步方法(逻辑特殊,单独封装)
/// </summary>
/// <param name="cookie">当前Cookie信息</param>
/// <param name="config">应用配置</param>
#region
private async Task SyncFollowListAsync(DouyinCookie cookie, AppConfig config)
{
// 1. 校验SecUserId是否有效
if (string.IsNullOrWhiteSpace(cookie.SecUserId))
{
Serilog.Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:未设置SecUserId,跳过");
Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:未设置SecUserId,跳过");
return;
}
var followList = new List<FollowingsItem>();
string offset = "0";
bool hasMore = true;
int total = 0;
List<FollowingsItem> followList = new List<FollowingsItem>();
FollowErrorDto currentError = null;
try
{
// 2. 分页获取关注列表数据
while (hasMore)
{
var (data, error) = await FetchFollowPageDataAsync(cookie, offset);
// 3. 处理错误信息
if (error != null && error.StatusCode != 0)
{
Serilog.Log.Error($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:发生错误,状态码:{error.StatusCode},错误信息:{error.StatusMsg}");
// 无效Cookie(未登录)直接停止分页
if (error.StatusCode == INVALID_COOKIE_STATUS_CODE)
var data = await _douyinService.SyncMyFollows(
DEFAULT_FOLLOW_COUNT, offset, cookie.SecUserId, cookie.Cookies,
async (err) =>
{
cookie.StatusMsg = "无效";
cookie.StatusCode = INVALID_COOKIE_STATUS_CODE;
currentError = err;
cookie.StatusMsg = err.StatusCode == INVALID_COOKIE_STATUS_CODE ? "无效" : "正常";
cookie.StatusCode = err.StatusCode;
await _dyCookieService.UpdateAsync(cookie);
break;
}
}
});
// 4. 处理有效数据
if (data != null)
if (currentError != null && currentError.StatusCode != 0)
{
// 5. 更新MyUserId(若未设置)
if (string.IsNullOrWhiteSpace(cookie.MyUserId))
{
cookie.MyUserId = data.MySelfUserId;
await _dyCookieService.UpdateAsync(cookie);
}
// 6. 更新分页状态和数据
total = data.Total;
hasMore = data.HasMore;
offset = data.Offset.ToString();
if (data.Followings != null && data.Followings.Any())
{
followList.AddRange(data.Followings);
Serilog.Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:获取到{data.Followings.Count}条关注数据,累计{followList.Count}条");
}
// 7. 非首次运行时,仅同步第一页数据
if (!config.IsFirstRunning)
{
hasMore = false;
//Serilog.Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:非首次运行,停止分页获取");
}
Log.Error($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:错误 状态码:{currentError.StatusCode} 信息:{currentError.StatusMsg}");
if (currentError.StatusCode == INVALID_COOKIE_STATUS_CODE) break;
}
else
if (data == null) break;
if (string.IsNullOrWhiteSpace(cookie.MyUserId))
{
Serilog.Log.Warning($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:获取分页数据为空,停止分页");
break;
cookie.MyUserId = data.MySelfUserId;
await _dyCookieService.UpdateAsync(cookie);
}
total = data.Total;
hasMore = data.HasMore;
offset = data.Offset.ToString();
if (data.Followings?.Any() == true)
{
followList.AddRange(data.Followings);
Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:获取{data.Followings.Count}条,累计{followList.Count}条");
}
if (!config.IsFirstRunning) hasMore = false;
}
// 8. 同步关注列表到数据库
if (followList.Any())
{
var (add, update, succ) = await _followService.Sync(followList, cookie);
Serilog.Log.Information($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:同步完成新增{add}条,更新{update}条,是否成功{succ}总关注数{total}");
}
else
{
Serilog.Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:无有效关注数据需要同步");
Log.Information($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:同步完成 新增:{add} 更新:{update} 成功:{succ} 总关注数:{total}");
}
}
catch (Exception ex)
{
Serilog.Log.Error(ex, $"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:同步失败,异常信息:{ex.Message}");
Log.Error(ex, $"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:同步失败");
}
}
/// <summary>
/// 分页获取关注列表数据(封装回调逻辑,简化代码)
/// </summary>
/// <param name="cookie">当前Cookie信息</param>
/// <param name="offset">分页偏移量</param>
private async Task<(DouyinFollowInfoResponse Data, FollowErrorDto Error)> FetchFollowPageDataAsync(DouyinCookie cookie, string offset)
{
DouyinFollowInfoResponse resultData = null;
FollowErrorDto resultError = null;
resultData = await _douyinService.SyncMyFollows(
DEFAULT_FOLLOW_COUNT,
offset,
cookie.SecUserId,
cookie.Cookies,
async (err) =>
{
resultError = err;
// 更新Cookie状态(异步不阻塞)
cookie.StatusMsg = err.StatusCode == INVALID_COOKIE_STATUS_CODE ? "无效" : "正常";
cookie.StatusCode = err.StatusCode;
await _dyCookieService.UpdateAsync(cookie);
});
// 若原方法是回调式无返回值,需调整封装逻辑
return (resultData, resultError);
}
#endregion
}
}
}
+20 -4
View File
@@ -13,19 +13,35 @@ namespace dy.net.job
}
protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_favorite;
protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_mix;
protected override async Task<DouyinVideoInfoResponse> FetchVideoData(DouyinCookie cookie, string cursor,DouyinFollowed followed,DouyinCollectCate cate)
{
return await douyinHttpClientService.SyncFavoriteVideos(count, cursor, cookie.SecUserId, cookie.Cookies);
return await douyinHttpClientService.SyncMixViedosByMixId(cursor,count,cookie.Cookies,cate.XId);
}
protected override bool ShouldContinueSync(DouyinCookie cookie, DouyinVideoInfoResponse data, DouyinFollowed followed=null)
{
return data != null && data.HasMore == 1 && cookie.FavHasSyncd == 0;
return data != null && data.HasMore == 1;
}
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (cate != null)
{
var folder = Path.Combine(cookie.SavePath,VideoType.GetDesc(), DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, cate.Name, true));
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder;
}
else
{
return base.CreateSaveFolder(cookie, item, config, followed, cate);
}
}
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.SavePath, "author");
}
}
}
+19
View File
@@ -25,5 +25,24 @@ namespace dy.net.job
{
return data != null && data.HasMore == 1;
}
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{
if (cate != null)
{
var folder = Path.Combine(cookie.SavePath, VideoType.GetDesc(), DouyinFileNameHelper.SanitizeLinuxFileName(cate.SaveFolder, cate.Name, true));
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder;
}
else
{
return base.CreateSaveFolder(cookie, item, config, followed, cate);
}
}
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.SavePath, "author");
}
}
}
+4
View File
@@ -19,6 +19,10 @@ namespace dy.net.model.dto
public string? Tag { get; set; }
public string? SortField { get; set; }
public string? SortOrder { get; set; }
}
+40
View File
@@ -0,0 +1,40 @@
namespace dy.net.model.dto
{
public class VideoChartItemDto
{
/// <summary>
/// 日期,格式MM-DD(如01-21
/// </summary>
public string Date { get; set; } = string.Empty;
/// <summary>
/// 我喜欢的数量
/// </summary>
public int Favorite { get; set; }
/// <summary>
/// 我收藏的数量
/// </summary>
public int Collect { get; set; }
/// <summary>
/// 我关注的数量
/// </summary>
public int Follow { get; set; }
/// <summary>
/// 图文视频数量
/// </summary>
public int Graphic { get; set; }
/// <summary>
/// 合集数量
/// </summary>
public int Mix { get; set; }
/// <summary>
/// 短剧数量
/// </summary>
public int Series { get; set; }
}
}
+5
View File
@@ -31,11 +31,16 @@
public string VideoFollowSize { get; set; }
public string VideoFavoriteSize { get; set; }
public string VideoMixSize { get; set; }
public string VideoSeriesSize { get; set; }
public long CollectCount { get; set; }
public long FavoriteCount { get; set; }
public long FollowCount { get; set; }
public long MixCount { get; set; }
public long SeriesCount { get; set; }
public long GraphicVideoCount { get; set; }
public string GraphicVideoSize { get; set; }
+2 -2
View File
@@ -33,7 +33,7 @@ namespace dy.net.model.entity
/// <summary>
/// 是否博主视频直接放一个根目录,不另外按名字建文件夹
/// </summary>
public bool UperSaveTogether { get; set; }
//public bool UperSaveTogether { get; set; }
/// <summary>
/// 是否下载图片视频
/// </summary>
@@ -68,7 +68,7 @@ namespace dy.net.model.entity
/// <summary>
/// 图文视频是否单独存放,否的话则按原类型存储位置存放,比如收藏夹、喜欢等
/// </summary>
public bool ImageViedoSaveAlone { get; set; }
//public bool ImageViedoSaveAlone { get; set; }
+9 -1
View File
@@ -35,10 +35,12 @@ namespace dy.net.model.entity
/// <summary>
/// 封面
/// </summary>
[SugarColumn(Length =500,IsNullable =true)]
public string CoverUrl { get; set; }
/// <summary>
/// 保存文件夹
/// </summary>
[SugarColumn(Length =500,IsNullable =true)]
public string SaveFolder { get; set; }
/// <summary>
/// 是否开启同步
@@ -52,10 +54,16 @@ namespace dy.net.model.entity
public DateTime CreateTime { get; set; }
public DateTime UpdateTime { get; set; }
[SugarColumn(IsNullable =true)]
public DateTime? UpdateTime { get; set; }
/// <summary>
/// 是否已完结
/// </summary>
public bool IsEnd { get; set; }
/// <summary>
/// 总集数
/// </summary>
public int Total { get; set; }
}
}
+24 -12
View File
@@ -12,7 +12,7 @@ namespace dy.net.model.entity
/// <summary>
/// 用户抖音ID,对应我关注信息里面的myself_user_id
/// </summary>
[SugarColumn(Length =500,IsNullable =true)]
[SugarColumn(Length = 500, IsNullable = true)]
public string MyUserId { get; set; }
/// <summary>
/// 用户描述
@@ -21,42 +21,42 @@ namespace dy.net.model.entity
/// <summary>
/// 用户抖音Cookie
/// </summary>
[SugarColumn(Length =-1,IsNullable =true)]
[SugarColumn(Length = -1, IsNullable = true)]
public string Cookies { get; set; }
/// <summary>
/// 存储路径(收藏视频的存储路径)
/// </summary>
[SugarColumn(Length =255,IsNullable =true)]
[SugarColumn(Length = 255, IsNullable = true)]
public string SavePath { get; set; }
/// <summary>
/// 是否按收藏夹文件夹来存储视频
/// </summary>
public bool UseCollectFolder { get; set; }
public bool UseCollectFolder { get; set; }
/// <summary>
/// 是否下载合集
/// </summary>
public bool DownMix { get; set; }
public bool DownMix { get; set; }
/// <summary>
/// 是否下载短剧
/// </summary>
public bool DownSeries { get; set; }
public bool DownSeries { get; set; }
/// <summary>
/// 1 开启同步 0 关闭同步
/// </summary>
public int Status { get; set; }
public int Status { get; set; }
/// <summary>
/// 同步喜欢的视频需要sec_user_id
/// </summary>
[SugarColumn(Length =500,IsNullable =true)]
[SugarColumn(Length = 500, IsNullable = true)]
public string SecUserId { get; set; }
/// <summary>
/// 喜欢的视频存储路径
/// </summary>
[SugarColumn(Length =500,IsNullable =true)]
[SugarColumn(Length = 500, IsNullable = true)]
public string FavSavePath { get; set; }
///// <summary>
@@ -95,8 +95,8 @@ namespace dy.net.model.entity
/// <summary>
/// 图片视频存储路径
/// </summary>
[SugarColumn(Length = 500, IsNullable = true)]
public string ImgSavePath { get; set; }
//[SugarColumn(Length = 500, IsNullable = true)]
//public string ImgSavePath { get; set; }
/// <summary>
/// 抖音返回的状态码,主要用于判断Cookie是否有效
@@ -109,11 +109,23 @@ namespace dy.net.model.entity
/// </summary>
[SugarColumn(Length = 100, IsNullable = true)]
public string StatusMsg { get; set; }
/// <summary>
/// 是否统一一个路径(savepath)
/// </summary>
public bool useSinglePath { get; set; }
[SugarColumn(Length = 100, IsNullable = true, ColumnName = "useSinglePath")]
public bool UseSinglePath { get; set; } = true;//默认true
///// <summary>
///// 合集存储路径
///// </summary>
//[SugarColumn(Length = 500, IsNullable = true)]
//public string MixPath { get; set; }
///// <summary>
///// 短剧存储路径
///// </summary>
//[SugarColumn(Length = 500, IsNullable = true)]
//public string SeriesPath { get; set; }
}
}
+6
View File
@@ -165,5 +165,11 @@ namespace dy.net.model.entity
/// </summary>
[SugarColumn(Length =200,IsNullable =true)]
public string CateId { get; set; }
/// <summary>
/// 自定义收藏夹、合集、短剧 绑定的XId
/// </summary>
[SugarColumn(Length = 200, IsNullable = true)]
public string CateXId { get; set; }
}
}
+5 -5
View File
@@ -169,11 +169,11 @@ namespace dy.net.model.response
//[JsonProperty("share_info")]
//public DouyinShareInfo ShareInfo { get; set; }
///// <summary>
///// 统计信息
///// </summary>
//[JsonProperty("statis")]
//public DouyinMixStatis Statis { get; set; }
/// <summary>
/// 统计信息
/// </summary>
[JsonProperty("statis")]
public DouyinMixStatis Statis { get; set; }
///// <summary>
///// 状态信息
+17 -17
View File
@@ -167,8 +167,8 @@ namespace dy.net.model.response
//[JsonProperty("share_info")]
//public DouyinDramaShareInfo ShareInfo { get; set; }
//[JsonProperty("stats")]
//public DouyinDramaStats Stats { get; set; }
[JsonProperty("stats")]
public DouyinDramaStats Stats { get; set; }
//[JsonProperty("status")]
//public DouyinDramaStatus DramaStatus { get; set; }
@@ -1087,26 +1087,26 @@ namespace dy.net.model.response
/// <summary>
/// 抖音短剧统计信息
/// </summary>
//public class DouyinDramaStats
//{
// [JsonProperty("collect_vv")]
// public int CollectViewCount { get; set; }
public class DouyinDramaStats
{
[JsonProperty("collect_vv")]
public int CollectViewCount { get; set; }
// [JsonProperty("current_episode")]
// public int CurrentEpisode { get; set; }
[JsonProperty("current_episode")]
public int CurrentEpisode { get; set; }
// [JsonProperty("last_added_item_time")]
// public long LastEpisodeAddTimestamp { get; set; }
[JsonProperty("last_added_item_time")]
public long LastEpisodeAddTimestamp { get; set; }
// [JsonProperty("play_vv")]
// public long TotalPlayViewCount { get; set; }
[JsonProperty("play_vv")]
public long TotalPlayViewCount { get; set; }
// [JsonProperty("total_episode")]
// public int TotalEpisodeCount { get; set; }
[JsonProperty("total_episode")]
public int TotalEpisodeCount { get; set; }
// [JsonProperty("updated_to_episode")]
// public int LatestEpisodeCount { get; set; }
//}
[JsonProperty("updated_to_episode")]
public int LatestEpisodeCount { get; set; }
}
/// <summary>
/// 抖音短剧状态信息
+53 -53
View File
@@ -2,7 +2,6 @@
using System.Collections.Generic;
namespace dy.net.model.response
{
/// <summary>
/// 视频列表
@@ -245,8 +244,8 @@ namespace dy.net.model.response
//[JsonProperty("media_type")]
//public int MediaType { get; set; }
//[JsonProperty("mix_info")]
//public MixInfo MixInfo { get; set; }
[JsonProperty("mix_info")]
public MixInfo MixInfo { get; set; }
[JsonProperty("music")]
public Music Music { get; set; }
@@ -619,59 +618,59 @@ namespace dy.net.model.response
// public int Type { get; set; }
//}
//public class MixInfo
//{
// [JsonProperty("cover_url")]
// public ImageInfo CoverUrl { get; set; }
public class MixInfo
{
[JsonProperty("cover_url")]
public ImageInfo CoverUrl { get; set; }
// [JsonProperty("create_time")]
// public long CreateTime { get; set; }
//[JsonProperty("create_time")]
//public long CreateTime { get; set; }
// [JsonProperty("desc")]
// public string Desc { get; set; }
//[JsonProperty("desc")]
//public string Desc { get; set; }
// [JsonProperty("enable_ad")]
// public int EnableAd { get; set; }
//[JsonProperty("enable_ad")]
//public int EnableAd { get; set; }
// [JsonProperty("extra")]
// public string Extra { get; set; }
//[JsonProperty("extra")]
//public string Extra { get; set; }
// [JsonProperty("ids")]
// public object Ids { get; set; }
//[JsonProperty("ids")]
//public object Ids { get; set; }
// [JsonProperty("is_iaa")]
// public int IsIaa { get; set; }
//[JsonProperty("is_iaa")]
//public int IsIaa { get; set; }
// [JsonProperty("is_serial_mix")]
// public int IsSerialMix { get; set; }
//[JsonProperty("is_serial_mix")]
//public int IsSerialMix { get; set; }
// [JsonProperty("mix_id")]
// public string MixId { get; set; }
//[JsonProperty("mix_id")]
//public string MixId { get; set; }
// [JsonProperty("mix_name")]
// public string MixName { get; set; }
//[JsonProperty("mix_name")]
//public string MixName { get; set; }
// [JsonProperty("mix_pic_type")]
// public int MixPicType { get; set; }
//[JsonProperty("mix_pic_type")]
//public int MixPicType { get; set; }
// [JsonProperty("mix_type")]
// public int MixType { get; set; }
//[JsonProperty("mix_type")]
//public int MixType { get; set; }
// [JsonProperty("share_info")]
// public MixShareInfo ShareInfo { get; set; }
//[JsonProperty("share_info")]
//public MixShareInfo ShareInfo { get; set; }
// [JsonProperty("statis")]
// public MixStatis Statis { get; set; }
[JsonProperty("statis")]
public MixStatis Statis { get; set; }
// [JsonProperty("status")]
// public MixStatus Status { get; set; }
//[JsonProperty("status")]
//public MixStatus Status { get; set; }
// [JsonProperty("update_time")]
// public long UpdateTime { get; set; }
//[JsonProperty("update_time")]
//public long UpdateTime { get; set; }
// [JsonProperty("watched_item")]
// public string WatchedItem { get; set; }
//}
//[JsonProperty("watched_item")]
//public string WatchedItem { get; set; }
}
//public class MixShareInfo
//{
@@ -697,20 +696,20 @@ namespace dy.net.model.response
// public string ShareWeiboDesc { get; set; }
//}
//public class MixStatis
//{
// [JsonProperty("collect_vv")]
// public int CollectVv { get; set; }
public class MixStatis
{
[JsonProperty("collect_vv")]
public int CollectVv { get; set; }
// [JsonProperty("current_episode")]
// public int CurrentEpisode { get; set; }
[JsonProperty("current_episode")]
public int CurrentEpisode { get; set; }
// [JsonProperty("play_vv")]
// public int PlayVv { get; set; }
[JsonProperty("play_vv")]
public int PlayVv { get; set; }
// [JsonProperty("updated_to_episode")]
// public int UpdatedToEpisode { get; set; }
//}
[JsonProperty("updated_to_episode")]
public int UpdatedToEpisode { get; set; }
}
//public class MixStatus
//{
@@ -765,8 +764,8 @@ namespace dy.net.model.response
//[JsonProperty("collect_stat")]
//public int CollectStat { get; set; }
//[JsonProperty("cover_hd")]
//public ImageInfo CoverHd { get; set; }
[JsonProperty("cover_hd")]
public ImageInfo CoverHd { get; set; }
//[JsonProperty("cover_large")]
//public ImageInfo CoverLarge { get; set; }
@@ -1285,4 +1284,5 @@ namespace dy.net.model.response
}
}
+21 -4
View File
@@ -3,6 +3,7 @@ using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.utils;
using SqlSugar;
using System;
using System.Linq.Expressions;
namespace dy.net.repository
@@ -103,8 +104,11 @@ namespace dy.net.repository
// 用Dictionary查找,效率更高
if (dbCateDict.TryGetValue(cate.XId, out var existCate))
{
// 对比字段是否有变化(根据实际业务字段调整)
bool isChanged = !string.Equals(existCate.Name, cate.Name, StringComparison.Ordinal);
// 对比字段是否有变化
bool isChanged = !string.Equals(existCate.Name, cate.Name, StringComparison.Ordinal) ||
!string.Equals(existCate.CoverUrl, cate.CoverUrl, StringComparison.Ordinal) ||
!string.Equals(existCate.SaveFolder, cate.SaveFolder, StringComparison.Ordinal) ||
existCate.Total != cate.Total;
if (isChanged)
{
@@ -131,7 +135,6 @@ namespace dy.net.repository
updateCount = await Db.Updateable(toUpdateCates)
.IgnoreColumns(x => new { x.Sync, x.CreateTime, x.CookieId })
.IgnoreNullColumns() // 忽略空值字段
.Where(c => c.Id == c.Id) // 批量更新默认按主键匹配,此处显式指定(也可省略,SqlSugar自动识别主键)
.ExecuteCommandAsync();
}
@@ -154,12 +157,26 @@ namespace dy.net.repository
// 异常时回滚事务(补充:避免Db.Ado未初始化事务导致的空引用)
await Db.Ado?.RollbackTranAsync();
isSuccess = false;
Serilog.Log.Error($"同步收藏夹:类型-{cateType}异常,{ex.StackTrace}");
Serilog.Log.Error($"同步{cateType.GetDesc()}时发生异常,{ex.StackTrace}");
}
//Serilog.Log.Debug($"本次收藏夹{cateType}同步结果,新增:{addCount},更新:{updateCount},删除:{deleteCount}");
// 8. 返回最终结果
return (addCount, updateCount, deleteCount, isSuccess);
}
/// <summary>
/// 短剧、合集完结
/// </summary>
/// <param name="cate"></param>
/// <returns></returns>
public async Task<bool> UpdateCateEndStatus(DouyinCollectCate cate)
{
var videoCounts = await Db.Queryable<DouyinVideo>().Where(x => x.CateId == cate.Id && x.CateXId == cate.XId && x.ViedoType == cate.CateType).CountAsync();
cate.IsEnd = videoCounts == cate.Total;
var update = await Db.Updateable(cate).UpdateColumns(x => new { x.IsEnd }).ExecuteCommandAsync();
return update > 0;
}
}
}
+11 -4
View File
@@ -55,7 +55,14 @@ namespace dy.net.repository
var totalCount = await where.CountAsync();
var list = await where.OrderByDescending(x => x.SyncTime).Skip((dto.PageIndex - 1) * dto.PageSize).Take(dto.PageSize).ToListAsync();
List<DouyinVideo> list = new List<DouyinVideo>();
if(string.IsNullOrWhiteSpace(dto.SortField))
list = await where.OrderByDescending(x => x.SyncTime).Skip((dto.PageIndex - 1) * dto.PageSize).Take(dto.PageSize).ToListAsync();
else
{
list = await where.OrderBy($"{dto.SortField} {dto.SortOrder}").Skip((dto.PageIndex - 1) * dto.PageSize).Take(dto.PageSize).ToListAsync();
}
if (list.Any())
{
var users = await this.Db.Queryable<DouyinCookie>().ToListAsync();
@@ -93,12 +100,12 @@ namespace dy.net.repository
/// <param name="AuthorId"></param>
/// <param name="ViedoNameSimplify"></param>
/// <returns></returns>
public async Task<(string, string)> GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify)
public (string, string) GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify)
{
var video = await this.Db.Queryable<DouyinVideo>().Where(x => x.AuthorId == AuthorId && x.ViedoType == VideoTypeEnum.dy_follows)
var video = this.Db.Queryable<DouyinVideo>().Where(x => x.AuthorId == AuthorId && x.ViedoType == VideoTypeEnum.dy_follows)
.Where(x => x.VideoTitleSimplify == ViedoNameSimplify)
.OrderByDescending(x => x.CreateTime).FirstAsync();
.OrderByDescending(x => x.CreateTime).First();
if (video != null)
{
+17 -7
View File
@@ -11,18 +11,18 @@ namespace dy.net.service
public class DouyinCollectCateService
{
private readonly DouyinCollectCateRepository douyinCollectCateRepository;
private readonly DouyinCollectCateRepository _douyinCollectCateRepository;
public DouyinCollectCateService(DouyinCollectCateRepository douyinCollectCateRepository)
{
douyinCollectCateRepository = douyinCollectCateRepository;
_douyinCollectCateRepository = douyinCollectCateRepository;
}
public async Task<bool> AddAsync(DouyinCollectCate cate)
{
return await douyinCollectCateRepository.InsertAsync(cate);
return await _douyinCollectCateRepository.InsertAsync(cate);
}
@@ -35,7 +35,7 @@ namespace dy.net.service
/// <returns></returns>
public async Task<(List<DouyinCollectCate> list, int totalCount)> GetPagedAsync(DouyinCollectCateRequestDto dto)
{
return await douyinCollectCateRepository.GetPagedAsync(dto);
return await _douyinCollectCateRepository.GetPagedAsync(dto);
}
/// <summary>
@@ -47,7 +47,7 @@ namespace dy.net.service
/// <returns></returns>
public async Task<(int add, int update,int delete, bool succ)> Sync(List<DouyinCollectCate> cates, string ckId,VideoTypeEnum cateType)
{
return await douyinCollectCateRepository.Sync(cates, ckId, cateType);
return await _douyinCollectCateRepository.Sync(cates, ckId, cateType);
}
@@ -58,7 +58,7 @@ namespace dy.net.service
/// <returns></returns>
public async Task<bool> BatchSwitchSync(List<DouyinCollectCateSwitchDto> dto)
{
return await douyinCollectCateRepository.SwitchBatchAsync(dto);
return await _douyinCollectCateRepository.SwitchBatchAsync(dto);
}
@@ -70,7 +70,17 @@ namespace dy.net.service
/// <returns></returns>
public async Task<List<DouyinCollectCate>> GetSyncCates(string cookieId, VideoTypeEnum cateType)
{
return await douyinCollectCateRepository.GetListAsync(x => x.CookieId == cookieId && x.CateType == cateType);
return await _douyinCollectCateRepository.GetListAsync(x => x.CookieId == cookieId && x.CateType == cateType && x.Sync && x.Total > 0);
}
/// <summary>
/// 完结
/// </summary>
/// <param name="cate"></param>
/// <returns></returns>
public async Task<bool> UpdateCate2EndStatus(DouyinCollectCate cate)
{
return await _douyinCollectCateRepository.UpdateCateEndStatus(cate);
}
}
+1 -2
View File
@@ -49,12 +49,11 @@ namespace dy.net.service
Cron = 30,
BatchCount = 18,
LogKeepDay = 7,
UperSaveTogether = false,//博主视频:true-->每个视频单独一个文件夹 false-->所有视频放在同一个文件夹
//UperSaveTogether = false,//博主视频:true-->每个视频单独一个文件夹 false-->所有视频放在同一个文件夹
UperUseViedoTitle = false,//博主视频:true-->使用视频标题作为文件名 false-->使用视频id作为文件名
DownImageVideo = true,//默认下载图文视频
DownMp3 = false,
DownImage = false,
ImageViedoSaveAlone = true,
FollowedTitleTemplate = "",
FullFollowedTitleTemplate = "",
FollowedTitleSeparator = "",
+3 -3
View File
@@ -53,14 +53,14 @@ namespace dy.net.service
var cookie = new DouyinCookie
{
UserName = "douyin2026",
Cookies = "-",
SecUserId = "-",
Cookies = "",
SecUserId = "",
Id = IdGener.GetLong().ToString(),
Status = 0,
SavePath = "/app/collect",
FavSavePath = "/app/favorite",
UpSavePath = "/app/uper",
ImgSavePath="/app/images",
//ImgSavePath="/app/images",
CollHasSyncd = 0,
FavHasSyncd = 0,
UperSyncd = 0,
+12 -11
View File
@@ -158,7 +158,7 @@ namespace dy.net.service
requestParameters["cursor"] = cursor; //页码
}
var respose = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie);
var respose = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie);
if (respose.IsSuccessStatusCode)
{
var data = await respose.Content.ReadAsStringAsync();
@@ -220,12 +220,13 @@ namespace dy.net.service
var requestParameters = DouyinRequestParamManager.DouyinFolderCollectParams;
{
count = "15";
requestParameters["cursor"] = cursor;
requestParameters["count"] = count;
requestParameters["collects_id"] = collectsId;
}
var respose = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie);
var respose = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie);
if (respose.IsSuccessStatusCode)
{
@@ -278,7 +279,7 @@ namespace dy.net.service
requestParameters["cursor"] = cursor; //页码
}
var respose = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie);
var respose = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie);
if (respose.IsSuccessStatusCode)
{
var data = await respose.Content.ReadAsStringAsync();
@@ -342,11 +343,11 @@ namespace dy.net.service
var requestParameters = DouyinRequestParamManager.DouyinMixVideoParams;
{
requestParameters["cursor"] = cursor;
requestParameters["count"] = count;
requestParameters["count"] = "15";
requestParameters["mix_id"] = mixId;
}
var respose = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie);
var respose = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie);
if (respose.IsSuccessStatusCode)
{
@@ -394,11 +395,11 @@ namespace dy.net.service
var requestParameters = DouyinRequestParamManager.DouyinSeriesListParams;
{
// 添加动态参数
requestParameters["count"] = "100";
requestParameters["count"] = "15";//固定15,多了直接返回参数不合法
requestParameters["cursor"] = cursor; //页码
}
var respose = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie);
var respose = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie);
if (respose.IsSuccessStatusCode)
{
var data = await respose.Content.ReadAsStringAsync();
@@ -466,7 +467,7 @@ namespace dy.net.service
requestParameters["series_id"] = seriesId;
}
var respose = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie);
var respose = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie);
if (respose.IsSuccessStatusCode)
{
@@ -538,7 +539,7 @@ namespace dy.net.service
requestParameters["sec_user_id"] = secUserId;
requestParameters["count"] = count;
}
var respose = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie);
var respose = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie);
if (respose.IsSuccessStatusCode)
{
@@ -610,7 +611,7 @@ namespace dy.net.service
requestParameters["sec_user_id"] = secUserId;
requestParameters["count"] = count;
}
var respose = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie);
var respose = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie);
if (respose.IsSuccessStatusCode)
{
@@ -683,7 +684,7 @@ namespace dy.net.service
requestParameters["offset"] = offset;
}
var respose = await GetHttpResponseMessage(HttpMethod.Post, requestUrl, requestParameters, refererValue, cookie);
var respose = await GetHttpResponseMessage(HttpMethod.Get, requestUrl, requestParameters, refererValue, cookie);
if (respose.IsSuccessStatusCode)
{
var data = await respose.Content.ReadAsStringAsync();
+78 -96
View File
@@ -1,9 +1,11 @@
using dy.net.job;
using dy.net.model.dto;
using dy.net.utils;
using Quartz;
using Quartz.Impl.Matchers;
using Serilog;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace dy.net.service
@@ -19,19 +21,11 @@ namespace dy.net.service
private const int DefaultCronStartDelaySeconds = 30;
private const int DefaultSimpleStartDelaySeconds = 3;
// 任务顺序依赖配置(核心:定义执行顺序,供 Listener 使用
public Dictionary<string, string> JobDependency { get; } = new()
{
{"collect", "favorite"}, // collect 执行完 → 触发 favorite
{"favorite", "followed"}, // favorite 执行完 → 触发 followed
{"followed", null}, // followed 执行完 → 一轮任务结束
};
// 任务配置信息(保持原有配置不变,改为 public 供 Listener 访问)
public Dictionary<string, JobConfig> JobConfigs { get; } = new()
// 任务配置信息(修复了series任务的Key重复问题,确保每个任务Key唯一
public static Dictionary<string, JobConfig> JobConfigs { get; } = new()
{
{
"collect",
VideoTypeEnum.dy_collects.GetDesc(),
new JobConfig(
typeof(DouyinCollectSyncJob),
"dy.job.key.collect",
@@ -39,7 +33,7 @@ namespace dy.net.service
"抖音收藏同步任务")
},
{
"favorite",
VideoTypeEnum.dy_favorite.GetDesc(),
new JobConfig(
typeof(DouyinFavoritSyncJob),
"dy.job.key.favorite",
@@ -47,12 +41,12 @@ namespace dy.net.service
"抖音点赞同步任务")
},
{
"followed",
VideoTypeEnum.dy_follows.GetDesc(),
new JobConfig(
typeof(DouyinFollowedViedoSyncJob),
typeof(DouyinFollowedSyncJob),
"dy.job.key.followed",
"dy.trigger.key.followed",
"抖音UP主作品同步任务")
"抖音关注博主作品同步任务")
},
{
"follow_user",
@@ -60,7 +54,31 @@ namespace dy.net.service
typeof(DouyinFollowsAndCollnectsSyncJob),
"dy.job.key.follow_user",
"dy.trigger.key.follow_user",
"抖音关注同步任务")
"抖音关注列表同步任务")
},
{
VideoTypeEnum.dy_custom_collect.GetDesc(),
new JobConfig(
typeof(DouyinCollectCustomSyncJob),
"dy.job.key.custom_collect",
"dy.trigger.key.custom_collect",
"抖音自定义收藏夹列表同步任务")
},
{
VideoTypeEnum.dy_mix.GetDesc(),
new JobConfig(
typeof(DouyinMixSyncJob),
"dy.job.key.mix",
"dy.trigger.key.mix",
"抖音收藏夹合集同步任务")
},
{
VideoTypeEnum.dy_series.GetDesc(),
new JobConfig(
typeof(DouyinSeriesSyncJob),
"dy.job.key.series",
"dy.trigger.key.series",
"抖音收藏夹短剧同步任务")
},
{
"follow_user_once",
@@ -78,9 +96,9 @@ namespace dy.net.service
}
/// <summary>
/// 启动所有抖音相关定时任务(顺序执行模式
/// 启动所有抖音相关定时任务(所有任务独立执行
/// </summary>
/// <param name="expression">Cron表达式或间隔分钟数(控制整个链条的执行频率)</param>
/// <param name="expression">Cron表达式或间隔分钟数(所有任务使用相同的执行频率)</param>
/// <returns>是否启动成功</returns>
public async Task<bool> InitOrReStartAllJobs(string expression)
{
@@ -94,31 +112,37 @@ namespace dy.net.service
{
var scheduler = await _schedulerFactory.GetScheduler();
// 1. 注册独立的 JobListener(核心:注入配置和服务
await RegisterJobListener(scheduler);
// 2. 移除所有已存在的任务(避免重复调度)
// 移除所有已存在的任务(避免重复调度
await RemoveAllExistingJobs(scheduler);
// 3. 只启动第一个任务(collect),后续任务由 Listener 自动触发
var firstJobConfigKey = "collect";
var startSuccess = await StartJobAsync(firstJobConfigKey, expression);
// 遍历启动所有任务(独立执行,无顺序依赖)
var allJobKeys = JobConfigs.Keys.Where(k => k != "follow_user_once").ToList(); // 排除单次执行的任务
foreach (var jobKey in allJobKeys)
{
if (jobKey == "follow_user")
{
expression = "60";
}
var startSuccess = await StartJobAsync(jobKey, expression);
if (startSuccess)
{
Log.Debug($"成功启动任务:{jobKey},执行频率:{expression}");
}
else
{
Log.Error($"启动任务失败:{jobKey}");
}
}
if (startSuccess)
{
Log.Debug($"同步任务执行顺序:collect → favorite → followed-->默认每{expression}分钟执行一次...");
}
else
{
Log.Error($"任务执行失败(任务 {firstJobConfigKey} 启动失败)");
}
//启动follow_user--这个与其他几个任务没有依赖关系,所以单独启动
await StartJobAsync("follow_user", expression);
return startSuccess;
Log.Information($"共启动 {allJobKeys.Count} 个定时任务,执行频率:{expression}");
//await StartJobAsync(VideoTypeEnum.dy_custom_collect.GetDesc(), expression);
return true;
}
catch (Exception ex)
{
Log.Error(ex, "【任务服务】初始化任务链条异常");
Log.Error(ex, "【任务服务】初始化所有定时任务异常");
return false;
}
}
@@ -131,27 +155,6 @@ namespace dy.net.service
return await StartOneTimeJobAsync("follow_user_once");
}
/// <summary>
/// 注册独立的 JobListener(核心步骤)
/// </summary>
private async Task RegisterJobListener(IScheduler scheduler)
{
// 创建独立的 Listener 实例,注入依赖(任务配置、依赖关系、当前服务)
var dependencyListener = new DouyinJobDependencyListener(
JobConfigs, // 任务配置
JobDependency, // 依赖顺序
this // 任务服务(用于触发下一个任务)
);
// 注册 Listener:仅监听 DefaultJobGroup 分组的任务(精准匹配,避免影响其他任务)
scheduler.ListenerManager.AddJobListener(
dependencyListener,
GroupMatcher<JobKey>.GroupEquals(DefaultJobGroup)
);
Log.Information("【任务服务】JobListener 注册成功:{ListenerName}", dependencyListener.Name);
}
/// <summary>
/// 移除所有已存在的任务(避免重复调度)
/// </summary>
@@ -169,13 +172,12 @@ namespace dy.net.service
}
/// <summary>
/// 启动指定定时任务(public 修饰,供 Listener 调用
/// 启动指定定时任务(独立执行,无依赖触发
/// </summary>
/// <param name="configKey">任务配置Key(如:collect、favorite</param>
/// <param name="expression">定时表达式(依赖触发时传空</param>
/// <param name="isDependencyTrigger">是否为依赖触发(true=立即执行,false=定时执行)</param>
/// <param name="expression">定时表达式(Cron或间隔分钟数</param>
/// <returns>是否启动成功</returns>
public async Task<bool> StartJobAsync(string configKey, string expression, bool isDependencyTrigger = false)
public async Task<bool> StartJobAsync(string configKey, string expression)
{
if (!JobConfigs.TryGetValue(configKey, out var jobConfig))
{
@@ -187,33 +189,26 @@ namespace dy.net.service
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup);
// 触发器Key:区分「定时触发」和「依赖触发」,避免冲突
var triggerKey = new TriggerKey(
$"{jobConfig.TriggerKey}_{(isDependencyTrigger ? "dependency" : "main")}",
DefaultJobGroup
);
var triggerKey = new TriggerKey(jobConfig.TriggerKey, DefaultJobGroup);
// 移除已存在的任务(防止重复执行)
await RemoveExistingJobAsync(scheduler, jobKey);
// 创建任务详情(添加禁止并发执行特性,避免顺序混乱
// 创建任务详情(保留禁止并发执行,避免同一任务重复运行
var jobDetail = JobBuilder.Create(jobConfig.JobType)
.WithIdentity(jobKey)
.WithDescription(jobConfig.Description)
.DisallowConcurrentExecution() // 关键:禁止同一任务并发执行
.DisallowConcurrentExecution() // 禁止同一任务并发执行
.Build();
// 创建触发器
ITrigger trigger = isDependencyTrigger
? CreateDependencyTrigger(triggerKey, jobConfig.Description) // 依赖触发:立即执行
: CreateScheduledTrigger(triggerKey, expression, jobConfig.Description); // 定时触发:按表达式执行
// 创建定时触发器(仅使用定时触发,移除依赖触发逻辑)
ITrigger trigger = CreateScheduledTrigger(triggerKey, expression, jobConfig.Description);
// 调度任务
await scheduler.ScheduleJob(jobDetail, trigger);
Log.Information("【任务服务】启动任务成功 - 任务描述: {JobDescription}, 触发类型: {TriggerType}, 表达式: {Expression}",
Log.Information("【任务服务】启动任务成功 - 任务描述: {JobDescription}, 执行频率: {Expression}",
jobConfig.Description,
isDependencyTrigger ? "依赖触发(立即执行)" : "定时触发",
isDependencyTrigger ? "无" : expression);
expression);
return true;
}
@@ -225,7 +220,7 @@ namespace dy.net.service
}
/// <summary>
/// 启动单次执行任务(保持原有逻辑不变)
/// 启动单次执行任务
/// </summary>
private async Task<bool> StartOneTimeJobAsync(string configKey)
{
@@ -268,9 +263,9 @@ namespace dy.net.service
}
/// <summary>
/// 创建定时触发器」(按表达式执行,仅第一个任务使用
/// 创建定时触发器(支持Cron表达式或分钟间隔
/// </summary>
private ITrigger CreateScheduledTrigger(TriggerKey triggerKey, string expression, string jobDescription)
private static ITrigger CreateScheduledTrigger(TriggerKey triggerKey, string expression, string jobDescription)
{
// Cron表达式格式
if (CronExpression.IsValidExpression(expression))
@@ -312,21 +307,9 @@ namespace dy.net.service
}
/// <summary>
/// 创建「依赖触发器」(立即执行,仅执行一次)
/// 移除已存在的任务
/// </summary>
private ITrigger CreateDependencyTrigger(TriggerKey triggerKey, string jobDescription)
{
return TriggerBuilder.Create()
.WithIdentity(triggerKey)
.WithDescription($"{jobDescription} - 依赖触发(立即执行)")
.StartNow() // 立即触发
.Build();
}
/// <summary>
/// 移除已存在的任务(保持原有逻辑不变)
/// </summary>
private async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey)
private static async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey)
{
if (await scheduler.CheckExists(jobKey))
{
@@ -334,6 +317,5 @@ namespace dy.net.service
await scheduler.DeleteJob(jobKey);
}
}
}
}
}
+371
View File
@@ -0,0 +1,371 @@
//using dy.net.job;
//using dy.net.model.dto;
//using Quartz;
//using Quartz.Impl.Matchers;
//using Serilog;
//using System;
//using System.Threading.Tasks;
//namespace dy.net.service
//{
// /// <summary>
// /// 抖音相关定时任务服务
// /// </summary>
// public class DouyinQuartzJobService
// {
// private readonly ISchedulerFactory _schedulerFactory;
// private const string DefaultJobGroup = "dysync.net";
// private const int DefaultIntervalMinutes = 30;
// private const int DefaultCronStartDelaySeconds = 30;
// private const int DefaultSimpleStartDelaySeconds = 3;
// // 任务顺序依赖配置(核心:定义执行顺序,供 Listener 使用)
// public Dictionary<string, string> JobDependency { get; } = new()
// {
// {"collect", "favorite"}, // collect 执行完 → 触发 favorite
// {"favorite", "followed"}, // favorite 执行完 → 触发 followed
// {"followed", null}, // followed 执行完 → 一轮任务结束
// };
// // 任务配置信息(保持原有配置不变,改为 public 供 Listener 访问)
// public Dictionary<string, JobConfig> JobConfigs { get; } = new()
// {
// {
// "collect",
// new JobConfig(
// typeof(DouyinCollectSyncJob),
// "dy.job.key.collect",
// "dy.trigger.key.collect",
// "抖音收藏同步任务")
// },
// {
// "favorite",
// new JobConfig(
// typeof(DouyinFavoritSyncJob),
// "dy.job.key.favorite",
// "dy.trigger.key.favorite",
// "抖音点赞同步任务")
// },
// {
// "followed",
// new JobConfig(
// typeof(DouyinFollowedSyncJob),
// "dy.job.key.followed",
// "dy.trigger.key.followed",
// "抖音关注博主作品同步任务")
// },
// {
// "follow_user",
// new JobConfig(
// typeof(DouyinFollowsAndCollnectsSyncJob),
// "dy.job.key.follow_user",
// "dy.trigger.key.follow_user",
// "抖音关注列表同步任务")
// },
// {
// "custom_collect",
// new JobConfig(
// typeof(DouyinCollectCustomSyncJob),
// "dy.job.key.custom_collect",
// "dy.trigger.key.custom_collect",
// "抖音自定义收藏夹列表同步任务")
// },
// {
// "mix",
// new JobConfig(
// typeof(DouyinMixSyncJob),
// "dy.job.key.mix",
// "dy.trigger.key.mix",
// "抖音收藏夹合集同步任务")
// },
// {
// "series",
// new JobConfig(
// typeof(DouyinSeriesSyncJob),
// "dy.job.key.mix",
// "dy.trigger.key.mix",
// "抖音收藏夹短剧同步任务")
// },
// {
// "follow_user_once",
// new JobConfig(
// typeof(DouyinFollowsAndCollnectsSyncJob),
// "dy.job.key.follow_user_once",
// "dy.trigger.key.follow_user_once",
// "抖音关注同步任务(单次执行)")
// }
// };
// public DouyinQuartzJobService(ISchedulerFactory schedulerFactory)
// {
// _schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory));
// }
// /// <summary>
// /// 启动所有抖音相关定时任务(顺序执行模式)
// /// </summary>
// /// <param name="expression">Cron表达式或间隔分钟数(控制整个链条的执行频率)</param>
// /// <returns>是否启动成功</returns>
// public async Task<bool> InitOrReStartAllJobs(string expression)
// {
// if (string.IsNullOrWhiteSpace(expression))
// {
// Log.Debug("定时任务表达式为空,使用默认配置({DefaultMinutes}分钟)", DefaultIntervalMinutes);
// expression = DefaultIntervalMinutes.ToString();
// }
// try
// {
// var scheduler = await _schedulerFactory.GetScheduler();
// // 1. 注册独立的 JobListener(核心:注入配置和服务)
// await RegisterJobListener(scheduler);
// // 2. 移除所有已存在的任务(避免重复调度)
// await RemoveAllExistingJobs(scheduler);
// // 3. 只启动第一个任务(collect),后续任务由 Listener 自动触发
// var firstJobConfigKey = "collect";
// //var startSuccess = await StartJobAsync(firstJobConfigKey, expression);
// //if (startSuccess)
// //{
// // Log.Debug($"同步任务执行顺序:collect → favorite → followed-->默认每{expression}分钟执行一次...");
// //}
// //else
// //{
// // Log.Error($"任务执行失败(任务 {firstJobConfigKey} 启动失败)");
// //}
// //启动follow_user--这个与其他几个任务没有依赖关系,所以单独启动
// //await StartJobAsync("collect", expression);
// //await StartJobAsync("follow_user", expression);
// //await StartJobAsync("follow_user", expression);
// //await StartJobAsync("follow_user", expression);
// //await StartJobAsync("custom_collect", expression);
// //await StartJobAsync("mix", expression);
// //await StartJobAsync("series", expression);
// return true;
// }
// catch (Exception ex)
// {
// Log.Error(ex, "【任务服务】初始化任务链条异常");
// return false;
// }
// }
// /// <summary>
// /// 启动关注同步任务(单次执行)
// /// </summary>
// public async Task<bool> StartFollowJobOnceAsync()
// {
// return await StartOneTimeJobAsync("follow_user_once");
// }
// /// <summary>
// /// 注册独立的 JobListener(核心步骤)
// /// </summary>
// private async Task RegisterJobListener(IScheduler scheduler)
// {
// // 创建独立的 Listener 实例,注入依赖(任务配置、依赖关系、当前服务)
// var dependencyListener = new DouyinJobDependencyListener(
// JobConfigs, // 任务配置
// JobDependency, // 依赖顺序
// this // 任务服务(用于触发下一个任务)
// );
// // 注册 Listener:仅监听 DefaultJobGroup 分组的任务(精准匹配,避免影响其他任务)
// scheduler.ListenerManager.AddJobListener(
// dependencyListener,
// GroupMatcher<JobKey>.GroupEquals(DefaultJobGroup)
// );
// Log.Information("【任务服务】JobListener 注册成功:{ListenerName}", dependencyListener.Name);
// }
// /// <summary>
// /// 移除所有已存在的任务(避免重复调度)
// /// </summary>
// private async Task RemoveAllExistingJobs(IScheduler scheduler)
// {
// var jobKeys = JobConfigs.Values.Select(config => new JobKey(config.JobKey, DefaultJobGroup)).ToList();
// foreach (var jobKey in jobKeys)
// {
// if (await scheduler.CheckExists(jobKey))
// {
// Log.Information("【任务服务】移除已存在的任务: {JobKey}", jobKey);
// await scheduler.DeleteJob(jobKey);
// }
// }
// }
// /// <summary>
// /// 启动指定定时任务(public 修饰,供 Listener 调用)
// /// </summary>
// /// <param name="configKey">任务配置Key(如:collect、favorite</param>
// /// <param name="expression">定时表达式(依赖触发时传空)</param>
// /// <param name="isDependencyTrigger">是否为依赖触发(true=立即执行,false=定时执行)</param>
// /// <returns>是否启动成功</returns>
// public async Task<bool> StartJobAsync(string configKey, string expression, bool isDependencyTrigger = false)
// {
// if (!JobConfigs.TryGetValue(configKey, out var jobConfig))
// {
// Log.Error("【任务服务】找不到任务配置: {ConfigKey}", configKey);
// return false;
// }
// try
// {
// var scheduler = await _schedulerFactory.GetScheduler();
// var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup);
// // 触发器Key:区分「定时触发」和「依赖触发」,避免冲突
// var triggerKey = new TriggerKey(
// $"{jobConfig.TriggerKey}_{(isDependencyTrigger ? "dependency" : "main")}",
// DefaultJobGroup
// );
// // 移除已存在的任务(防止重复执行)
// await RemoveExistingJobAsync(scheduler, jobKey);
// // 创建任务详情(添加禁止并发执行特性,避免顺序混乱)
// var jobDetail = JobBuilder.Create(jobConfig.JobType)
// .WithIdentity(jobKey)
// .WithDescription(jobConfig.Description)
// .DisallowConcurrentExecution() // 关键:禁止同一任务并发执行
// .Build();
// // 创建立触发器
// ITrigger trigger = isDependencyTrigger
// ? CreateDependencyTrigger(triggerKey, jobConfig.Description) // 依赖触发:立即执行
// : CreateScheduledTrigger(triggerKey, expression, jobConfig.Description); // 定时触发:按表达式执行
// // 调度任务
// await scheduler.ScheduleJob(jobDetail, trigger);
// Log.Information("【任务服务】启动任务成功 - 任务描述: {JobDescription}, 触发类型: {TriggerType}, 表达式: {Expression}",
// jobConfig.Description,
// isDependencyTrigger ? "依赖触发(立即执行)" : "定时触发",
// isDependencyTrigger ? "无" : expression);
// return true;
// }
// catch (Exception ex)
// {
// Log.Error(ex, "【任务服务】启动任务失败 - 任务描述: {JobDescription}", jobConfig.Description);
// return false;
// }
// }
// /// <summary>
// /// 启动单次执行任务(保持原有逻辑不变)
// /// </summary>
// private async Task<bool> StartOneTimeJobAsync(string configKey)
// {
// if (!JobConfigs.TryGetValue(configKey, out var jobConfig))
// {
// Log.Error("【任务服务】找不到任务配置: {ConfigKey}", configKey);
// return false;
// }
// try
// {
// var scheduler = await _schedulerFactory.GetScheduler();
// var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup);
// var triggerKey = new TriggerKey(jobConfig.TriggerKey, DefaultJobGroup);
// await RemoveExistingJobAsync(scheduler, jobKey);
// var jobDetail = JobBuilder.Create(jobConfig.JobType)
// .WithIdentity(jobKey)
// .WithDescription(jobConfig.Description)
// .DisallowConcurrentExecution()
// .Build();
// var trigger = TriggerBuilder.Create()
// .WithIdentity(triggerKey)
// .WithDescription($"{jobConfig.Description} - 单次执行")
// .StartNow()
// .Build();
// await scheduler.ScheduleJob(jobDetail, trigger);
// Log.Information("【任务服务】启动单次任务成功 - 任务描述: {JobDescription}", jobConfig.Description);
// return true;
// }
// catch (Exception ex)
// {
// Log.Error(ex, "【任务服务】启动单次任务失败 - 任务描述: {JobDescription}", jobConfig.Description);
// return false;
// }
// }
// /// <summary>
// /// 创建「定时触发器」(按表达式执行,仅第一个任务使用)
// /// </summary>
// private ITrigger CreateScheduledTrigger(TriggerKey triggerKey, string expression, string jobDescription)
// {
// // Cron表达式格式
// if (CronExpression.IsValidExpression(expression))
// {
// return TriggerBuilder.Create()
// .WithIdentity(triggerKey)
// .WithDescription($"{jobDescription} - Cron调度")
// .WithCronSchedule(expression)
// .StartAt(DateTime.Now.AddSeconds(DefaultCronStartDelaySeconds))
// .Build();
// }
// // 数字间隔格式(分钟)
// if (int.TryParse(expression, out int intervalMinutes))
// {
// intervalMinutes = Math.Max(1, intervalMinutes); // 最小间隔1分钟
// return TriggerBuilder.Create()
// .WithIdentity(triggerKey)
// .WithDescription($"{jobDescription} - 间隔{intervalMinutes}分钟调度")
// .StartAt(DateTime.Now.AddSeconds(DefaultSimpleStartDelaySeconds))
// .WithSimpleSchedule(x => x
// .WithIntervalInMinutes(intervalMinutes)
// .RepeatForever())
// .Build();
// }
// // 无效表达式,使用默认配置
// Log.Warning("【任务服务】无效的任务表达式: {Expression},使用默认间隔{DefaultMinutes}分钟",
// expression, DefaultIntervalMinutes);
// return TriggerBuilder.Create()
// .WithIdentity(triggerKey)
// .WithDescription($"{jobDescription} - 默认间隔调度")
// .StartAt(DateTime.Now.AddSeconds(DefaultSimpleStartDelaySeconds))
// .WithSimpleSchedule(x => x
// .WithIntervalInMinutes(DefaultIntervalMinutes)
// .RepeatForever())
// .Build();
// }
// /// <summary>
// /// 创建「依赖触发器」(立即执行,仅执行一次)
// /// </summary>
// private ITrigger CreateDependencyTrigger(TriggerKey triggerKey, string jobDescription)
// {
// return TriggerBuilder.Create()
// .WithIdentity(triggerKey)
// .WithDescription($"{jobDescription} - 依赖触发(立即执行)")
// .StartNow() // 立即触发
// .Build();
// }
// /// <summary>
// /// 移除已存在的任务(保持原有逻辑不变)
// /// </summary>
// private async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey)
// {
// if (await scheduler.CheckExists(jobKey))
// {
// Log.Information("【任务服务】移除已存在的任务: {JobKey}", jobKey);
// await scheduler.DeleteJob(jobKey);
// }
// }
//}
//}
+200 -5
View File
@@ -4,9 +4,11 @@ using dy.net.model.entity;
using dy.net.repository;
using dy.net.utils;
using Newtonsoft.Json;
using Serilog;
using SqlSugar;
using System.Collections.Generic;
using System.ComponentModel;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace dy.net.service
@@ -81,6 +83,7 @@ namespace dy.net.service
{
existingVideo.VideoSavePath = updateData.VideoSavePath;
existingVideo.VideoCoverSavePath = updateData.VideoCoverSavePath;
existingVideo.ViedoType = updateData.ViedoType;
}
}
// 批量更新数据库
@@ -94,6 +97,10 @@ namespace dy.net.service
return transaction;
}
public async Task<bool> UpdateOne(DouyinVideo video)
{
return await _dyCollectVideoRepository.UpdateAsync(video);
}
public async Task<VideoStaticsDto> GetStatics()
{
@@ -110,14 +117,17 @@ namespace dy.net.service
VideoCount = list.Count,
Categories = Categories,
FavoriteCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_favorite),
CollectCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_collects),
CollectCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_collects || x.ViedoType == VideoTypeEnum.dy_custom_collect),
FollowCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_follows),
GraphicVideoCount = list.Count(x => x.IsMergeVideo == 1),
MixCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_mix),
SeriesCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_series),
VideoSizeTotal = DouyinFileUtils.ConvertBytesToGb(list.Sum(x => x.FileSize)),
VideoFavoriteSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_favorite).Sum(x => x.FileSize)),
VideoCollectSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_collects).Sum(x => x.FileSize)),
VideoCollectSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_collects || x.ViedoType == VideoTypeEnum.dy_custom_collect).Sum(x => x.FileSize)),
VideoFollowSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_follows).Sum(x => x.FileSize)),
VideoMixSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_mix).Sum(x => x.FileSize)),
VideoSeriesSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_series).Sum(x => x.FileSize)),
GraphicVideoSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.IsMergeVideo == 1).Sum(x => x.FileSize)),
//TotalDiskSize= ByteToGbConverter.GetHostTotalDiskSpaceGB(),
@@ -129,6 +139,28 @@ namespace dy.net.service
data.GraphicVideoSize = "<0.01";//避免显示0.00误导用户
}
}
if (data.VideoFavoriteSize == "0.00")
{
data.VideoFavoriteSize = "<0.01";//避免显示0.00误导用户
}
if (data.VideoCollectSize == "0.00")
{
data.VideoCollectSize = "<0.01";//避免显示0.00误导用户
}
if (data.VideoFollowSize == "0.00")
{
data.VideoFollowSize = "<0.01";//避免显示0.00误导用户
}
if (data.VideoMixSize == "0.00")
{
data.VideoMixSize = "<0.01";//避免显示0.00误导用户
}
if (data.VideoSeriesSize == "0.00")
{
data.VideoSeriesSize = "<0.01";//避免显示0.00误导用户
}
data.Authors = list.GroupBy(x => x.Author).Select(x => new VideoStaticsItemDto
{
Name = x.Key,
@@ -170,10 +202,10 @@ namespace dy.net.service
/// <param name="AuthorId"></param>
/// <param name="ViedoNameSimplify"></param>
/// <returns></returns>
public async Task<(string, string)> GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify)
public (string, string) GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify)
{
return await _dyCollectVideoRepository.GetUperLastViedoFileName(AuthorId, ViedoNameSimplify);
return _dyCollectVideoRepository.GetUperLastViedoFileName(AuthorId, ViedoNameSimplify);
}
/// <summary>
@@ -346,7 +378,27 @@ namespace dy.net.service
{
return await _dyCollectVideoRepository.GetTopsOrderBySyncTime(top);
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public async Task<List<VideoChartItemDto>> GetChartData()
{
var list = await _dyCollectVideoRepository.GetListAsync(x => x.SyncTime > DateTime.Now.AddDays(-7));
var resultData = list.GroupBy(x => x.SyncTime.ToString("yyyyMMdd")).Select(g => new VideoChartItemDto
{
Date = g.Key,
Collect = g.Count(x => x.ViedoType == VideoTypeEnum.dy_collects || x.ViedoType == VideoTypeEnum.dy_custom_collect),
Favorite = g.Count(x => x.ViedoType == VideoTypeEnum.dy_favorite),
Follow = g.Count(x => x.ViedoType != VideoTypeEnum.dy_follows),
Graphic = g.Count(x => string.IsNullOrEmpty(x.FileHash)),
Mix = g.Count(x => x.ViedoType != VideoTypeEnum.dy_mix),
Series = g.Count(x => x.ViedoType != VideoTypeEnum.dy_series),
})
.ToList();
return resultData;
}
/// <summary>
/// 删除无效记录(记录存在,用户手动把目录下的视频删了的情况,视频记录依然存在)
@@ -456,5 +508,148 @@ namespace dy.net.service
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public async Task<bool> HandOldFolderVideos()
{
// 1. 查询目标数据
var list = await _dyCollectVideoRepository.GetListAsync(x=>x.ViedoType == VideoTypeEnum.dy_favorite || x.ViedoType == VideoTypeEnum.dy_collects );
// 缓存已处理的「Tag1+下一级文件夹」组合(避免重复移动)
var processedFolderPairs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in list)
{
// 跳过空值(Tag1/Author为空)
if (string.IsNullOrEmpty(item.Tag1) || string.IsNullOrEmpty(item.Author))
{
Log.Debug($"跳过:Tag1/Author为空,ItemId={item.Id}");
continue;
}
try
{
// 2. 标准化路径 + 拆分路径层级(核心:精准定位Tag1和其下一级文件夹)
string oldVideoPath = item.VideoSavePath;
// 统一分隔符为/,方便拆分层级
string standardPath = oldVideoPath.Replace('\\', '/').Trim('/');
string[] pathSegments = standardPath.Split('/'); // 拆分结果:["app","collect","校园教育","期末复习xxx","文件.mp4"]
// 2.1 找到Tag1在路径中的索引(比如"校园教育"的索引是2
int tag1Index = Array.IndexOf(pathSegments, item.Tag1);
if (tag1Index == -1 || tag1Index + 1 >= pathSegments.Length - 1)
{
Log.Debug($"跳过:无Tag1下一级文件夹,Path={oldVideoPath}Tag1={item.Tag1}");
continue;
}
// 2.2 解析核心路径(关键!)
string tag1FolderRelative = string.Join("/", pathSegments.Take(tag1Index + 1)); // Tag1根目录(相对):app/collect/校园教育
// Tag1根目录完整路径(如 D:/app/collect/校园教育 或 /app/collect/校园教育)
string tag1RootFolderFull = Path.GetFullPath(
Path.Combine(Path.GetPathRoot(oldVideoPath) ?? "",
tag1FolderRelative.Replace('/', Path.DirectorySeparatorChar))
);
string tag1NextLevelFolderName = pathSegments[tag1Index + 1]; // Tag1下一级文件夹名:期末复习xxx
// Tag1下一级文件夹完整路径:app/collect/校园教育/期末复习xxx
string tag1NextLevelFolderFull = Path.GetFullPath(
Path.Combine(tag1RootFolderFull, tag1NextLevelFolderName)
);
// 2.3 拼接新路径(替换Tag1为Author,保留下一级文件夹名)
// 新Author根目录完整路径:app/collect/张三
string authorRootFolderFull = tag1RootFolderFull.Replace(item.Tag1, item.Author);
// 新的下一级文件夹完整路径:app/collect/张三/期末复习xxx
string authorNextLevelFolderFull = Path.GetFullPath(
Path.Combine(authorRootFolderFull, tag1NextLevelFolderName)
);
// 2.4 防重复处理(Tag1下一级文件夹已处理则跳过)
string folderPairKey = $"{tag1NextLevelFolderFull}|{authorNextLevelFolderFull}";
if (processedFolderPairs.Contains(folderPairKey))
{
Log.Debug($"跳过:下一级文件夹已处理,Key={folderPairKey}");
continue;
}
// 3. 核心判断:检查Tag1的下一级文件夹是否有文件(而非Tag1根目录)
if (!Directory.Exists(tag1NextLevelFolderFull))
{
Log.Warning($"跳过:Tag1下一级文件夹不存在,Path={tag1NextLevelFolderFull}");
continue;
}
string[] nextLevelFiles = Directory.GetFiles(tag1NextLevelFolderFull); // 非递归,只查该文件夹下的文件
if (nextLevelFiles.Length == 0)
{
Log.Debug($"跳过:Tag1下一级文件夹无文件,Path={tag1NextLevelFolderFull}");
processedFolderPairs.Add(folderPairKey);
continue;
}
// 4. 移动Tag1的下一级文件夹(保留文件夹名,整体移动到Author目录下)
// 4.1 确保新Author目录存在
Directory.CreateDirectory(authorRootFolderFull);
// 4.2 目标文件夹已存在则跳过(如需覆盖,可删除此行+添加Directory.Delete(authorNextLevelFolderFull, true)
if (Directory.Exists(authorNextLevelFolderFull))
{
Log.Debug($"跳过:目标下一级文件夹已存在,Path={authorNextLevelFolderFull}");
processedFolderPairs.Add(folderPairKey);
continue;
}
// 4.3 移动整个下一级文件夹(保留名称和内部所有文件)
Directory.Move(tag1NextLevelFolderFull, authorNextLevelFolderFull);
Log.Debug($"移动Tag1下一级文件夹成功:{tag1NextLevelFolderFull} → {authorNextLevelFolderFull}");
// 5. 关键新增:检查Tag1根目录是否为空,为空则删除
if (Directory.Exists(tag1RootFolderFull))
{
// 检查Tag1根目录下是否还有任何文件/文件夹
bool isTag1RootEmpty = !Directory.EnumerateFileSystemEntries(tag1RootFolderFull).Any();
if (isTag1RootEmpty)
{
Directory.Delete(tag1RootFolderFull, false); // false=仅删除空目录,避免误删
Log.Debug($"删除空Tag1根目录:{tag1RootFolderFull}");
}
else
{
Log.Debug($"Tag1根目录非空,不删除:{tag1RootFolderFull}");
}
}
// 标记已处理
processedFolderPairs.Add(folderPairKey);
// 6. 更新当前Item的视频路径(替换Tag1为Author,保留后续层级)
string newVideoPath = oldVideoPath.Replace(item.Tag1, item.Author);
item.VideoSavePath = newVideoPath;
Log.Debug($"更新Item路径:{oldVideoPath} → {newVideoPath}");
}
catch (IOException ex)
{
Log.Error(ex, $"移动失败(IO异常),ItemId={item.Id}Path={item.VideoSavePath}");
}
catch (UnauthorizedAccessException ex)
{
Log.Error(ex, $"删除/移动失败(权限不足),ItemId={item.Id}Path={item.VideoSavePath}");
}
catch (Exception ex)
{
Log.Error(ex, $"处理失败(未知错误),ItemId={item.Id}Path={item.VideoSavePath}");
}
}
// 批量更新数据库
if (list.Any())
{
await BatchInsertOrUpdate(list);
Log.Debug($"批量更新数据库完成,共处理{list.Count}条数据");
}
return true;
}
}
}
+56 -33
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using dy.net.model.dto;
using System.Collections.Generic;
namespace dy.net.utils
{
@@ -8,6 +9,46 @@ namespace dy.net.utils
/// </summary>
public static class DouyinRequestParamManager
{
#region
/// <summary>
/// 私有静态只读基础参数模板(仅创建1次)
/// </summary>
private static readonly Dictionary<string, string> _baseParamTemplate = new Dictionary<string, string>
{
{"device_platform", "webapp"},
{"aid", "6383"},
{"channel", "channel_pc_web"},
{"pc_client_type", "1"},
{"pc_libra_divert", "Windows"},
{"cookie_enabled", "true"},
{"browser_language", "zh-CN"},
{"browser_platform", "Win32"},
{"browser_name", "Chrome"},
{"browser_online", "true"},
{"engine_name", "Blink"},
{"os_name", "Windows"},
{"os_version", "10"},
{"device_memory", "8"},
{"platform", "PC"},
{"downlink", "10"},
{"effective_type", "4g"},
{"round_trip_time", "0"},
{"update_version_code", "170400"},
{"whale_cut_token", ""}
};
/// <summary>
///
/// </summary>
/// <returns></returns>
private static Dictionary<string, string> InitBaseParams()
{
return new Dictionary<string, string>(_baseParamTemplate);
}
#endregion
#region
/// <summary>
/// 抖音全局域名
@@ -399,42 +440,24 @@ namespace dy.net.utils
}
#endregion
#region
/// <summary>
/// 私有静态只读基础参数模板(仅创建1次)
/// </summary>
private static readonly Dictionary<string, string> _baseParamTemplate = new Dictionary<string, string>
{
{"device_platform", "webapp"},
{"aid", "6383"},
{"channel", "channel_pc_web"},
{"pc_client_type", "1"},
{"pc_libra_divert", "Windows"},
{"cookie_enabled", "true"},
{"browser_language", "zh-CN"},
{"browser_platform", "Win32"},
{"browser_name", "Chrome"},
{"browser_online", "true"},
{"engine_name", "Blink"},
{"os_name", "Windows"},
{"os_version", "10"},
{"device_memory", "8"},
{"platform", "PC"},
{"downlink", "10"},
{"effective_type", "4g"},
{"round_trip_time", "0"},
{"update_version_code", "170400"},
{"whale_cut_token", ""}
};
/// <summary>
///
/// 获取视频类型名称
/// </summary>
/// <param name="videoType"></param>
/// <returns></returns>
private static Dictionary<string, string> InitBaseParams()
public static string GetDesc(this VideoTypeEnum videoType)
{
return new Dictionary<string, string>(_baseParamTemplate);
return videoType switch
{
VideoTypeEnum.dy_favorite => "喜欢",
VideoTypeEnum.dy_collects => "默认收藏夹",
VideoTypeEnum.dy_follows => "关注",
VideoTypeEnum.ImageVideo => "图文视频",
VideoTypeEnum.dy_custom_collect => "自定义收藏夹",
VideoTypeEnum.dy_mix => "合集",
VideoTypeEnum.dy_series => "短剧",
_ => string.Empty // 匹配所有未定义的枚举值,返回空字符串(替代原 default)
};
}
#endregion
}
}
+20 -8
View File
@@ -45,17 +45,17 @@ namespace dy.net.utils
}
var nfoActorFullPath = Path.Combine(actorsDir, $"{video.Author}{fileExt}");
if (File.Exists(nfoActorFullPath))
if (!File.Exists(nfoActorFullPath))
{
File.Delete(nfoActorFullPath);
File.Copy(video.AuthorAvatar, nfoActorFullPath, overwrite: true);
//File.Delete(video.AuthorAvatar);
}
// 执行复制(CopyTo支持覆盖,但先删除更可控)
File.Copy(video.AuthorAvatar, nfoActorFullPath, overwrite: true);
}
}
}
GenerateNfoFile(new DouyinVideoNfo
var nfoInfo= new DouyinVideoNfo
{
Actors = new List<Actor>
{
@@ -70,7 +70,19 @@ namespace dy.net.utils
Thumbnail = "poster.jpg",// 使用poster作为缩略图
ReleaseDate = video.CreateTime,
Genres = new List<string> { video.Tag1, video.Tag2, video.Tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
}, nfoFullPath);
};
if (video.ViedoType == VideoTypeEnum.dy_mix || video.ViedoType == VideoTypeEnum.dy_series)
{
string directory = Path.GetDirectoryName(nfoFullPath);
nfoFullPath = Path.Combine(directory, "tvshow.nfo");
if (!File.Exists(nfoFullPath))
GenerateNfoFile(nfoInfo, nfoFullPath, "tvshow");
}
else
{
GenerateNfoFile(nfoInfo, nfoFullPath);
}
}
catch (Exception ex)
{
@@ -78,7 +90,7 @@ namespace dy.net.utils
}
}
private static void GenerateNfoFile(DouyinVideoNfo videoInfo, string filePath)
private static void GenerateNfoFile(DouyinVideoNfo videoInfo, string filePath,string xmlRoot= "movie")
{
try
{
@@ -89,7 +101,7 @@ namespace dy.net.utils
throw new ArgumentException("文件路径不能为空", nameof(filePath));
// 创建根元素
XElement root = new XElement("movie");
XElement root = new XElement(xmlRoot);
//root.Add(new XElement("outline"));
root.Add(new XElement("lockdata", true));
//root.Add(new XElement("director", videoInfo.Author));