完成合集、短剧

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]")] [Route("api/[controller]")]
[ApiController] [ApiController]
[Authorize] [Authorize]
public class CollectController : ControllerBase public class CateController : ControllerBase
{ {
private readonly DouyinCollectCateService _douyinCollectCateService; private readonly DouyinCollectCateService _douyinCollectCateService;
private readonly DouyinQuartzJobService _douyinQuartzJobService; private readonly DouyinQuartzJobService _douyinQuartzJobService;
public CollectController(DouyinCollectCateService douyinCollectCateService, DouyinQuartzJobService douyinQuartzJobService) public CateController(DouyinCollectCateService douyinCollectCateService, DouyinQuartzJobService douyinQuartzJobService)
{ {
this._douyinCollectCateService = douyinCollectCateService; this._douyinCollectCateService = douyinCollectCateService;
_douyinQuartzJobService = douyinQuartzJobService; _douyinQuartzJobService = douyinQuartzJobService;
+4 -4
View File
@@ -197,10 +197,10 @@ namespace dy.net.Controllers
return ApiResult.Fail($"请在飞牛应用设置里面将{dyUserCookies.UpSavePath}添加读写权限"); return ApiResult.Fail($"请在飞牛应用设置里面将{dyUserCookies.UpSavePath}添加读写权限");
} }
if (!string.IsNullOrWhiteSpace(dyUserCookies.ImgSavePath) && !DouyinFileUtils.HasDirectoryReadWritePermission(dyUserCookies.ImgSavePath)) //if (!string.IsNullOrWhiteSpace(dyUserCookies.ImgSavePath) && !DouyinFileUtils.HasDirectoryReadWritePermission(dyUserCookies.ImgSavePath))
{ //{
return ApiResult.Fail($"请在飞牛应用设置里面将{dyUserCookies.ImgSavePath}添加读写权限"); // return ApiResult.Fail($"请在飞牛应用设置里面将{dyUserCookies.ImgSavePath}添加读写权限");
} //}
var checkCk = await httpClientService.CheckCookie(dyUserCookies); var checkCk = await httpClientService.CheckCookie(dyUserCookies);
+207 -29
View File
@@ -47,8 +47,7 @@ namespace dy.net.Controllers
[HttpGet("statics")] [HttpGet("statics")]
public async Task<IActionResult> GetStaticsAsync() public async Task<IActionResult> GetStaticsAsync()
{ {
var data = await douyinVideoService.GetStatics(); return ApiResult.Success(await douyinVideoService.GetStatics());
return ApiResult.Success(data);
} }
/// <summary> /// <summary>
@@ -68,7 +67,7 @@ namespace dy.net.Controllers
{ {
return ApiResult.Fail($"视频不存在:{vid}"); return ApiResult.Fail($"视频不存在:{vid}");
} }
return PlayViedo(viedo); return await PlayVideoAsync(viedo);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -76,11 +75,85 @@ namespace dy.net.Controllers
} }
} }
private IActionResult PlayViedo(DouyinVideo viedo) //private IActionResult PlayViedo(DouyinVideo viedo)
{ //{
// 1. 拼接完整物理路径(配置路径 + 文件名) // // 1. 拼接完整物理路径(配置路径 + 文件名)
string videoFullPath = viedo.VideoSavePath; // 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. 验证文件是否存在 // 2. 验证文件是否存在
if (!System.IO.File.Exists(videoFullPath)) if (!System.IO.File.Exists(videoFullPath))
@@ -88,36 +161,117 @@ namespace dy.net.Controllers
return ApiResult.Fail($"视频文件不存在:{videoFullPath}"); return ApiResult.Fail($"视频文件不存在:{videoFullPath}");
} }
// 3. 获取文件信息(大小、类型) try
{
// 3. 获取文件信息(使用using确保FileInfo资源释放)
var fileInfo = new FileInfo(videoFullPath); var fileInfo = new FileInfo(videoFullPath);
long fileSize = fileInfo.Length; 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)) if (fileSize == 0)
{ {
// 分片起始位置(前端请求的起始字节) return ApiResult.Fail($"视频文件为空:{videoFullPath}");
long end = Math.Min(start + 1024 * 1024 * 2, fileSize - 1); // 每片 2MB(可调整) }
string contentType = GetContentType(videoFullPath);
// 4. 处理分片请求(Range)
if (Request.Headers.ContainsKey("Range"))
{
return await HandleRangeRequestAsync(videoFullPath, fileSize, contentType);
}
else
{
// 完整文件请求(启用分片处理,兼容前端断点续传)
return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true);
}
}
catch (UnauthorizedAccessException)
{
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; long chunkSize = end - start + 1;
// 5. 设置分片响应头 // 设置206分片响应头
Response.StatusCode = StatusCodes.Status206PartialContent; Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}"); Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileSize}");
Response.Headers.Add("Accept-Ranges", "bytes"); Response.Headers.Add("Accept-Ranges", "bytes");
Response.Headers.Add("Content-Length", chunkSize.ToString()); Response.Headers.Add("Content-Length", chunkSize.ToString());
Response.ContentType = contentType;
// 6. 读取分片并返回流 // 核心改进:使用异步流 + using确保释放(通过管道直接写入响应流)
var stream = new FileStream(videoFullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true); await using var fileStream = new FileStream(
stream.Seek(start, SeekOrigin.Begin); filePath,
return new FileStreamResult(stream, contentType); FileMode.Open,
} FileAccess.Read,
else 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));
return PhysicalFile(videoFullPath, contentType, enableRangeProcessing: true); if (bytesRead == 0) break;
}
await Response.Body.WriteAsync(buffer.AsMemory(0, bytesRead));
remainingBytes -= bytesRead;
} }
await Response.Body.FlushAsync();
return new EmptyResult();
}
/// <summary> /// <summary>
/// 播放视频 /// 播放视频
@@ -144,7 +298,7 @@ namespace dy.net.Controllers
return ApiResult.Fail($"视频地址无效"); return ApiResult.Fail($"视频地址无效");
} }
return PlayViedo(viedo); return await PlayVideoAsync(viedo);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -155,16 +309,12 @@ namespace dy.net.Controllers
/// <summary> /// <summary>
/// 辅助方法:根据文件名获取 MIME 类型(确保前端正确识别视频格式) /// 辅助方法:根据文件名获取 MIME 类型(确保前端正确识别视频格式)
/// </summary> /// </summary>
private string GetContentType(string filename) private static string GetContentType(string filename)
{ {
string extension = Path.GetExtension(filename).ToLowerInvariant(); string extension = Path.GetExtension(filename).ToLowerInvariant();
return extension switch return extension switch
{ {
".mp4" => "video/mp4", ".mp4" => "video/mp4",
".webm" => "video/webm",
".ogg" => "video/ogg",
".mov" => "video/quicktime",
".avi" => "video/x-msvideo",
_ => "application/octet-stream" // 默认二进制流 _ => "application/octet-stream" // 默认二进制流
}; };
} }
@@ -342,5 +492,33 @@ namespace dy.net.Controllers
return ApiResult.Success(); 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(); commonService.UpdateAllCookieSyncedToZero();
// 初始化配置 // 初始化配置
var config = commonService.InitConfig(); var config = commonService.InitConfig();
if (!isDevelopment) //if (!isDevelopment)
{ {
// 启动定时任务 // 启动定时任务
var quartzJobService = services.GetRequiredService<DouyinQuartzJobService>(); var quartzJobService = services.GetRequiredService<DouyinQuartzJobService>();
quartzJobService.InitOrReStartAllJobs(config?.Cron <= 0 ? "30" : config.Cron.ToString()); quartzJobService.InitOrReStartAllJobs(config?.Cron <= 0 ? "30" : config.Cron.ToString());
DouyinHttpHelper.GetTenImage(Appsettings.Get("tagName"));//查询镜像版本
} }
// 初始化Cookie // 初始化Cookie
var deploy= Appsettings.Get("deploy"); var deploy= Appsettings.Get("deploy");
@@ -2,8 +2,8 @@
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. --> <!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<_PublishTargetUrl>E:\gitea\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl> <_PublishTargetUrl>E:\code\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> <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 /> <LastFailureDetails />
</PropertyGroup> </PropertyGroup>
</Project> </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/) 并登录目标账号; 1. 打开 **抖音网页版** (https://www.douyin.com/) 并登录目标账号;
2. 进入「我的收藏」页面,确保页面加载完成; 2. 进入个人主页,按下F12进入开发者模式、并切换到 `网络`(也可能叫`network`).
3. 按 `F12` 打开浏览器「开发者工具」,切换到「Network (网络)」标签; 3. 在筛选框中输入`/follow`.
4. 刷新页面,在搜索框中输入 `v1/web/aweme/listcollection` 筛选请求; 4. 点击自己头像边上的 `关注` 按钮、会弹出你的关注列表,然后在右侧网络请求里面会出现多个请求,随便选一个.
5. 点击任意一条筛选结果,在右侧「Headers (标头)」中找到 `Cookie` 字段,**完整复制整段内容**(不可删减字符)。 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` ### 1.2 提取 博主的`sec_user_id`以及博主的uid
- **个人 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.进入博主主页,按`F12` 点击`Network` 或`网络` 筛选器里面填`/web/aweme/post` 然后切到`预览`或`preview` 展开 json数据结果 找到`aweme_list` 然后随便点开其中一个子项 即可找到`author_user_id` 这便是博主的uid 。后续在关注列表中,需要手动添加非关注博主同步视频时将会要用到。 - 1.进入博主主页,按`F12` 点击`Network` 或`网络` 筛选器里面填`/web/aweme/post` 然后切到`预览`或`preview` 展开 json数据结果 找到`aweme_list` 然后随便点开其中一个子项 即可找到`author_user_id` 这便是博主的uid 。后续在关注列表中,需要手动添加非关注博主同步视频时将会要用到。
![获取Cookie步骤](docs/getuperuid.png) ![获取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 | | `arm_1.9.8` | ARM64 |
@@ -114,7 +111,7 @@ docker run -d --restart=always \
-v /opt/dysync/uper:/app/uper \ -v /opt/dysync/uper:/app/uper \
-p 10101:10101 \ -p 10101:10101 \
--name dysync2026 \ --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: services:
dysync: 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 # 容器名称 container_name: dysync2026 # 容器名称
restart: unless-stopped # 始终重启容器,除非容器被手动停止或Docker服务停止 restart: unless-stopped # 始终重启容器,除非容器被手动停止或Docker服务停止
ports: ports:
+104 -71
View File
@@ -29,7 +29,7 @@ columns.value = [
{ title: '收藏路径', dataIndex: 'savePath' }, { title: '收藏路径', dataIndex: 'savePath' },
{ title: '喜欢路径', dataIndex: 'favSavePath' }, { title: '喜欢路径', dataIndex: 'favSavePath' },
{ title: '博主路径', dataIndex: 'upSavePath' }, { title: '博主路径', dataIndex: 'upSavePath' },
{ title: '图文视频', dataIndex: 'imgSavePath' }, // { title: '图文视频', dataIndex: 'imgSavePath' },
// { title: 'Cookie', dataIndex: 'cookies' }, // { title: 'Cookie', dataIndex: 'cookies' },
{ title: '状态', dataIndex: 'status', width: 180 }, { title: '状态', dataIndex: 'status', width: 180 },
{ title: '操作', dataIndex: 'edit', width: 200 }, // 加宽操作列宽度 { title: '操作', dataIndex: 'edit', width: 200 }, // 加宽操作列宽度
@@ -54,11 +54,13 @@ type DataItem = {
upSecUserIdsJson?: UpSecUserIdItem[]; upSecUserIdsJson?: UpSecUserIdItem[];
upSecUserIds?: string; upSecUserIds?: string;
upSavePath?: string; upSavePath?: string;
imgSavePath?: string; // imgSavePath?: string;
useSinglePath?: boolean; // 新增:是否全部用一个地址 useSinglePath?: boolean; // 新增:是否全部用一个地址
useCollectFolder?: boolean; useCollectFolder?: boolean;
downMix?: boolean; downMix?: boolean;
downSeries?: boolean; downSeries?: boolean;
// mixPath?: string;
// seriesPath?: string;
}; };
const loading = ref(false); const loading = ref(false);
@@ -117,11 +119,13 @@ const newCookie = (cookie?: DataItem) => {
cookie.id = '0'; cookie.id = '0';
cookie.upSecUserIdsJson = undefined; cookie.upSecUserIdsJson = undefined;
cookie.upSavePath = undefined; cookie.upSavePath = undefined;
cookie.imgSavePath = undefined; // cookie.imgSavePath = undefined;
cookie.useSinglePath = false; // 新增:默认不使用单一路径 cookie.useSinglePath = false; // 新增:默认不使用单一路径
cookie.useCollectFolder = false; //是否按收藏夹来下载。 cookie.useCollectFolder = false; //是否按收藏夹来下载。
cookie.downMix = false; //是否下载收藏夹的合集 cookie.downMix = false; //是否下载收藏夹的合集
cookie.downSeries = false; //是否下载短剧 cookie.downSeries = false; //是否下载短剧
// cookie.mixPath = undefined; //合集存储路径
// cookie.seriesPath = undefined; //短剧存储路径
return cookie; return cookie;
}; };
@@ -141,7 +145,7 @@ watch(
if (useSinglePath && newSavePath) { if (useSinglePath && newSavePath) {
form.favSavePath = newSavePath; form.favSavePath = newSavePath;
form.upSavePath = newSavePath; form.upSavePath = newSavePath;
form.imgSavePath = newSavePath; // form.imgSavePath = newSavePath;
} }
}, },
{ immediate: true } { immediate: true }
@@ -220,6 +224,7 @@ const deleted = (id: string) => {
}; };
function edit(record: DataItem) { function edit(record: DataItem) {
cookieId.value = record.id;
editRecord.value = record; editRecord.value = record;
console.log(record); console.log(record);
copyObject(form, record); copyObject(form, record);
@@ -316,6 +321,10 @@ onMounted(() => {
const showDrawer = ref(false); const showDrawer = ref(false);
// 抽屉类型(区分收藏夹/合集/短剧) // 抽屉类型(区分收藏夹/合集/短剧)
type DrawerType = 'collect' | 'mix' | 'series'; type DrawerType = 'collect' | 'mix' | 'series';
const cookieId = ref('');
const cateType = ref(5);
const drawerType = ref<DrawerType>('collect'); const drawerType = ref<DrawerType>('collect');
// 抽屉滚动容器引用(用于监听触底分页) // 抽屉滚动容器引用(用于监听触底分页)
const drawerScrollRef = ref<HTMLDivElement | null>(null); const drawerScrollRef = ref<HTMLDivElement | null>(null);
@@ -332,28 +341,32 @@ const drawerPagination = reactive({
// ========== 定义抽屉数据项接口 ========== // ========== 定义抽屉数据项接口 ==========
interface DrawerItem { interface DrawerItem {
Id: string; // 不显示 id: string; // 不显示
Name: string; // 名称 name: string; // 名称
SaveFolder: string; // 保存文件夹 saveFolder: string; // 保存文件夹
Sync: boolean; // 是否同步 sync: boolean; // 是否同步
CoverUrl: string; // 封面 coverUrl: string; // 封面
CookieId: string; // 不显示 cookieId: string; // 不显示
XId: string; // 不显示 xId: string; // 不显示
total: number;
} }
// ========== 3个打开抽屉的方法 ========== // ========== 3个打开抽屉的方法 ==========
const openCollectFolderSetModal = () => { const openCollectFolderSetModal = () => {
drawerType.value = 'collect'; drawerType.value = 'collect';
cateType.value = 5;
openCommonDrawer(); openCommonDrawer();
}; };
const openMixDownSetModal = () => { const openMixDownSetModal = () => {
drawerType.value = 'mix'; drawerType.value = 'mix';
cateType.value = 6;
openCommonDrawer(); openCommonDrawer();
}; };
const openSeriesDownSetModal = () => { const openSeriesDownSetModal = () => {
drawerType.value = 'series'; drawerType.value = 'series';
cateType.value = 7;
openCommonDrawer(); openCommonDrawer();
}; };
@@ -401,36 +414,32 @@ const handleDrawerScroll = () => {
} }
}; };
// ========== 加载抽屉数据(模拟接口请求,可替换为真实接口) ========== // ========== 加载抽屉数据(
const loadDrawerData = () => { const loadDrawerData = () => {
if (drawerPagination.loading || !drawerPagination.hasMore) return; if (drawerPagination.loading || !drawerPagination.hasMore) return;
drawerPagination.loading = true; drawerPagination.loading = true;
useApiStore()
// 模拟接口请求(实际项目中替换为真实API调用) .CatePageList({
setTimeout(() => { cookieId: cookieId.value,
// 模拟数据(可根据drawerType返回不同类型数据) cateType: cateType.value,
const mockData: DrawerItem[] = Array.from({ length: drawerPagination.pageSize }, (_, index) => ({ })
Id: `${drawerPagination.current}-${index}`, .then((res) => {
Name: `${getDrawerTypeName()} ${(drawerPagination.current - 1) * drawerPagination.pageSize + index + 1}`, if (res.code === 0) {
SaveFolder: `./${drawerType.value}/${(drawerPagination.current - 1) * drawerPagination.pageSize + index + 1}`, drawerDataList.value = [...drawerDataList.value, ...res.data.data];
Sync: Math.random() > 0.5, drawerPagination.loading = false;
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.total = res.data.total;
drawerPagination.loading = false; drawerPagination.loading = false;
// 判断是否还有更多数据 // 判断是否还有更多数据
drawerPagination.hasMore = drawerDataList.value.length < drawerPagination.total; drawerPagination.hasMore = drawerDataList.value.length < drawerPagination.total;
}, 800); } else {
message.error(res.message);
}
})
.finally(() => {
drawerPagination.loading = false;
});
}; };
// ========== 获取抽屉类型名称(用于页面展示) ========== // ========== 获取抽屉类型名称(用于页面展示) ==========
@@ -443,15 +452,15 @@ const getDrawerTypeName = () => {
case 'series': case 'series':
return '短剧'; return '短剧';
default: default:
return '数据'; return '';
} }
}; };
// ========== 切换同步状态(可根据需求对接真实接口) ========== // ========== 切换同步状态(可根据需求对接真实接口) ==========
const toggleDrawerItemSync = (item: DrawerItem, index: number) => { const toggleDrawerItemSync = (item: DrawerItem, index: number) => {
if (drawerDataList.value[index]) { if (drawerDataList.value[index]) {
drawerDataList.value[index].Sync = !item.Sync; drawerDataList.value[index].sync = !item.sync;
console.log(`切换${getDrawerTypeName()}【${item.Name}】的同步状态为:${!item.Sync}`); console.log(`切换${getDrawerTypeName()}【${item.name}】的同步状态为:${!item.sync}`);
} }
}; };
// ========== 关闭抽屉清理资源 ========== // ========== 关闭抽屉清理资源 ==========
@@ -470,12 +479,20 @@ const saveDrawerData = () => {
message.info('暂无需要保存的配置数据'); message.info('暂无需要保存的配置数据');
return; return;
} }
// 模拟保存逻辑(实际项目中可替换为真实接口,提交 drawerDataList.value 数据)
drawerPagination.loading = true; drawerPagination.loading = true;
setTimeout(() => { useApiStore()
.BatchSaveCate(drawerDataList.value)
.then((res) => {
if (res.code === 0) {
showDrawer.value = false;
message.success('保存成功');
} else {
message.error(res.message);
}
})
.finally(() => {
drawerPagination.loading = false; drawerPagination.loading = false;
message.success(`${getDrawerTypeName()}配置保存成功`); });
}, 800);
}; };
</script> </script>
@@ -504,14 +521,14 @@ const saveDrawerData = () => {
<!-- 收藏的存储路径 --> <!-- 收藏的存储路径 -->
<a-form-item label="收藏的存储路径" name="savePath"> <a-form-item label="收藏的存储路径" name="savePath">
<div style="display: flex; align-items: center; gap: 12px; width: 100%;"> <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;" /> <a-alert message="不想同步收藏的视频就空着" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
</div> </div>
</a-form-item> </a-form-item>
<!-- 新增:是否全部用一个地址开关 --> <!-- 新增:是否全部用一个地址开关 -->
<a-form-item v-if="form.savePath&&form.savePath.length>0" label="是否统一存储路径" name="useSinglePath"> <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="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" /> <a-switch v-model:checked="form.useSinglePath" :checked-value="true" :un-checked-value="false" size="default" />
<span style="margin-left:10px;">{{form.useSinglePath ?'是':'否'}}</span> <span style="margin-left:10px;">{{form.useSinglePath ?'是':'否'}}</span>
</div> </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;" /> <a-alert message="开启后,所有视频都存储在收藏视频存储的路径,如果是容器部署:docker-compose配置,此时只需要映射一个路径' " :type="form.useSinglePath?'success':'info'" size="small" style="flex: 1; margin-bottom: 0;" />
</div> </div>
</a-form-item> </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"> <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="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" /> <a-switch v-model:checked="form.useCollectFolder" :checked-value="true" :un-checked-value="false" size="default" />
<span style="margin-left:10px;">{{ form.useCollectFolder ? '是' : '否' }}</span> <span style="margin-left:10px;">{{ form.useCollectFolder ? '是' : '否' }}</span>
<a-button @click="openCollectFolderSetModal" shape="circle" type="dashed" style="margin-left:10px;" v-if="form.useCollectFolder"> <a-button @click="openCollectFolderSetModal" shape="circle" type="dashed" style="margin-left:10px;" v-if="form.useCollectFolder">
<setting-outlined /> <setting-outlined />
</a-button> </a-button>
@@ -535,9 +569,11 @@ const saveDrawerData = () => {
<a-form-item v-if="form.savePath&&form.savePath.length>0" label="下载合集" name="downMix"> <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="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" /> <a-switch v-model:checked="form.downMix" :checked-value="true" :un-checked-value="false" size="default" />
<span style="margin-left:10px;">{{ form.downMix ? '是' : '否' }}</span> <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 @click="openMixDownSetModal" shape="circle" type="dashed" style="margin-left:10px;" v-if="form.downMix"> <setting-outlined />
</a-button> </a-button>
</div> </div>
@@ -548,9 +584,11 @@ const saveDrawerData = () => {
<a-form-item v-if="form.savePath&&form.savePath.length>0" label="下载短剧" name="downSeries"> <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="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" /> <a-switch v-model:checked="form.downSeries" :checked-value="true" :un-checked-value="false" size="default" />
<span style="margin-left:10px;">{{ form.downSeries ? '是' : '否' }}</span> <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 @click="openSeriesDownSetModal" shape="circle" type="dashed" style="margin-left:10px;" v-if="form.downSeries"> <setting-outlined />
</a-button> </a-button>
</div> </div>
@@ -559,29 +597,13 @@ const saveDrawerData = () => {
</div> </div>
</a-form-item> </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%;"> <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-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;" /> <a-alert message="如果系统配置页面开启了同步图文视频,且开启了单独存储,则必填!!!" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
</div> </div>
</a-form-item> </a-form-item> -->
<!-- 同步状态开关 --> <!-- 同步状态开关 -->
<a-form-item label="同步状态" name="status"> <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 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'"> <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>
<!-- 名称:横向一行展示(标签 + 内容 在同一行) --> <!-- 名称:横向一行展示(标签 + 内容 在同一行) -->
<div class="grid-item horizontal-item name"> <div class="grid-item horizontal-item name">
<label class="drawer-label">名称:</label> <label class="drawer-label">名称:</label>
<span>{{ item.Name || '未命名' }}</span> <span>{{ item.name || '未命名' }}</span>
</div> </div>
<!-- 保存文件夹:横向一行展示(标签 + 输入框 在同一行) --> <!-- 保存文件夹:横向一行展示(标签 + 输入框 在同一行) -->
<div class="grid-item horizontal-item save-folder"> <div class="grid-item horizontal-item save-folder">
<label class="drawer-label">保存路径:</label> <label class="drawer-label">保存:</label>
<a-input v-model:value="item.SaveFolder" size="small" placeholder="请输入保存路径" class="save-path-input" /> <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>
<!-- 同步开关(保持原有逻辑,横向对齐) --> <!-- 同步开关(保持原有逻辑,横向对齐) -->
<div class="grid-item horizontal-item sync-switch"> <div class="grid-item horizontal-item sync-switch">
<label class="drawer-label">是否同步:</label> <label class="drawer-label">同步:</label>
<a-switch :checked="item.Sync" @change="() => toggleDrawerItemSync(item, index)" size="small" /> <a-switch :checked="item.sync" @change="() => toggleDrawerItemSync(item, index)" size="small" />
</div> </div>
</a-card-grid> </a-card-grid>
</a-card> </a-card>
<!-- 无更多数据提示 --> <!-- 无更多数据提示 -->
<div v-if="!drawerPagination.hasMore && drawerDataList.length > 0" class="no-more-data"> <div v-if="!drawerPagination.hasMore && drawerDataList.length > 0" class="no-more-data">
已加载全部{{ getDrawerTypeName() }}数据 <!-- 已加载全部{{ getDrawerTypeName() }}数据 -->
</div> </div>
<!-- 加载下一页中 --> <!-- 加载下一页中 -->
@@ -756,6 +782,13 @@ const saveDrawerData = () => {
.cookie-content::-webkit-scrollbar-corner { .cookie-content::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
.form-item-div {
width: 300px;
}
.form-item-div-input {
width: 150px;
margin-left: 10px;
}
/* Firefox 透明滚动条适配 */ /* Firefox 透明滚动条适配 */
.cookie-content { .cookie-content {
scrollbar-width: thin; scrollbar-width: thin;
+5 -5
View File
@@ -22,7 +22,7 @@ type ConfigItem = {
secUserId: string; secUserId: string;
status: number; status: number;
upSavePath: string; upSavePath: string;
imgSavePath: string; // imgSavePath: string;
useSinglePath: boolean; // 非可选 useSinglePath: boolean; // 非可选
}; };
@@ -37,7 +37,7 @@ const newConfig = (config?: ConfigItem): ConfigItem => {
secUserId: '', secUserId: '',
status: 0, status: 0,
upSavePath: '', upSavePath: '',
imgSavePath: '', // imgSavePath: '',
useSinglePath: false, useSinglePath: false,
}; };
}; };
@@ -61,7 +61,7 @@ watch(
if (useSinglePath && newSavePath) { if (useSinglePath && newSavePath) {
form.value.favSavePath = newSavePath; form.value.favSavePath = newSavePath;
form.value.upSavePath = newSavePath; form.value.upSavePath = newSavePath;
form.value.imgSavePath = newSavePath; // form.value.imgSavePath = newSavePath;
} }
}, },
{ immediate: true } { immediate: true }
@@ -216,9 +216,9 @@ const manualCheckForm = (): { pass: boolean; msg: string } => {
<a-input v-model:value="form.upSavePath" placeholder="关注视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" /> <a-input v-model:value="form.upSavePath" placeholder="关注视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item> </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-input v-model:value="form.imgSavePath" placeholder="图文视频存储路径,不想同步就空着,后续可以在“抖音授权”修改" @input="() => {}" />
</a-form-item> </a-form-item> -->
<!-- 同步状态开关 --> <!-- 同步状态开关 -->
<a-form-item label="同步状态" name="status"> <a-form-item label="同步状态" name="status">
+5 -5
View File
@@ -88,7 +88,7 @@
<span>开启后,将图片文件和音频文件合成为视频文件</span> <span>开启后,将图片文件和音频文件合成为视频文件</span>
</div> </div>
</a-form-item> </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" /> <a-switch v-model:checked="formState.ImageViedoSaveAlone" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
@@ -96,7 +96,7 @@
开启后,图文视频统一存入抖音授权 Cookie 配置的目录,且需提前配置该存储路径。关闭后,则按类型分别存入对应文件夹(如收藏视频存入收藏视频目录) 开启后,图文视频统一存入抖音授权 Cookie 配置的目录,且需提前配置该存储路径。关闭后,则按类型分别存入对应文件夹(如收藏视频存入收藏视频目录)
</span> </span>
</div> </div>
</a-form-item> </a-form-item> -->
<a-form-item v-if="formState.DownImageVideo" has-feedback label="保留音频" name="DownMp3" :wrapper-col="{ span: 20 }"> <a-form-item v-if="formState.DownImageVideo" has-feedback label="保留音频" name="DownMp3" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownMp3" /> <a-switch v-model:checked="formState.DownMp3" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
@@ -304,7 +304,7 @@ interface FormState {
LogKeepDay: number; LogKeepDay: number;
DownImage: boolean; DownImage: boolean;
DownMp3: boolean; DownMp3: boolean;
ImageViedoSaveAlone: boolean; //ImageViedoSaveAlone: boolean;
FollowedTitleTemplate: string[]; FollowedTitleTemplate: string[];
FollowedTitleSeparator: string; FollowedTitleSeparator: string;
FullFollowedTitleTemplate: string; FullFollowedTitleTemplate: string;
@@ -327,7 +327,7 @@ const formState: UnwrapRef<FormState> = reactive({
DownImageVideo: false, DownImageVideo: false,
DownMp3: false, DownMp3: false,
DownImage: false, DownImage: false,
ImageViedoSaveAlone: true, // ImageViedoSaveAlone: true,
FollowedTitleTemplate: [], FollowedTitleTemplate: [],
FollowedTitleSeparator: '', FollowedTitleSeparator: '',
FullFollowedTitleTemplate: '', FullFollowedTitleTemplate: '',
@@ -403,7 +403,7 @@ const getConfig = () => {
FollowedTitleTemplate: parsedTemplateArr, FollowedTitleTemplate: parsedTemplateArr,
FollowedTitleSeparator: res.data.followedTitleSeparator || '', FollowedTitleSeparator: res.data.followedTitleSeparator || '',
FullFollowedTitleTemplate: fullTemplate, FullFollowedTitleTemplate: fullTemplate,
ImageViedoSaveAlone: res.data.imageViedoSaveAlone, // ImageViedoSaveAlone: res.data.imageViedoSaveAlone,
AutoDistinct: res.data.autoDistinct, AutoDistinct: res.data.autoDistinct,
PriorityLevel: res.data.priorityLevel, PriorityLevel: res.data.priorityLevel,
DownDynamicVideo: res.data.downDynamicVideo, DownDynamicVideo: res.data.downDynamicVideo,
+113 -12
View File
@@ -144,7 +144,7 @@
</a-modal> </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 #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'videoTitle'"> <template v-if="column.dataIndex === 'videoTitle'">
<a class="video-title-link" :title="record.videoTitle || '无标题'" @click="handleVideoClick(record)" @mouseenter="handleTitleMouseEnter" @mouseleave="handleTitleMouseLeave"> <a class="video-title-link" :title="record.videoTitle || '无标题'" @click="handleVideoClick(record)" @mouseenter="handleTitleMouseEnter" @mouseleave="handleTitleMouseLeave">
@@ -206,6 +206,11 @@ interface DataItem {
isMergeVideo?: boolean; isMergeVideo?: boolean;
} }
// 📌 新增:排序参数类型定义
interface SortParam {
field: string; // 排序字段
order: 'ascend' | 'descend' | ''; // 排序方向:升序/降序/无
}
interface QuaryParam { interface QuaryParam {
dates?: string[]; dates?: string[];
dates2?: string[]; dates2?: string[];
@@ -216,6 +221,8 @@ interface QuaryParam {
viedoType: string; viedoType: string;
fileHash: string; fileHash: string;
authorId: string; authorId: string;
sortField?: string; // 📌 新增:排序字段
sortOrder?: string; // 📌 新增:排序方向(asc/desc)
} }
// 引入dayjs中文包 // 引入dayjs中文包
@@ -226,6 +233,11 @@ dayjs.locale('zh-cn');
// 批量操作相关状态 // 批量操作相关状态
const isBatchMode = ref(false); // 批量操作开关状态 const isBatchMode = ref(false); // 批量操作开关状态
const selectedRowKeys = ref<string[]>([]); // 选中的行ID集合 const selectedRowKeys = ref<string[]>([]); // 选中的行ID集合
// 📌 新增:排序状态管理
const sortParams = ref<SortParam>({
field: 'syncTime', // 默认排序字段(发布时间)
order: 'descend', // 默认降序(最新的在前)
});
// 表格行选择器类型定义(对齐 Ant Design Vue 3.x 规范) // 表格行选择器类型定义(对齐 Ant Design Vue 3.x 规范)
interface CustomTableRowSelection<T> { interface CustomTableRowSelection<T> {
@@ -255,7 +267,6 @@ const rowSelection = computed<CustomTableRowSelection<DataItem>>(() => ({
}), }),
})); }));
// 表格列配置(优化:临时注释 fixed: right 避免渲染冲突)
const columns = ref([ const columns = ref([
{ {
title: '同步时间', title: '同步时间',
@@ -268,6 +279,13 @@ const columns = ref([
dataIndex: 'createTimeStr', dataIndex: 'createTimeStr',
align: 'center', align: 'center',
width: 180, width: 180,
sorter: true,
sortOrder: sortParams.value.field === 'createTime' ? sortParams.value.order : null,
onHeaderCell: () => ({
onClick: () => {
handleSortChange('createTime');
},
}),
}, },
{ {
title: '同步类型', title: '同步类型',
@@ -280,6 +298,13 @@ const columns = ref([
dataIndex: 'author', dataIndex: 'author',
align: 'center', align: 'center',
width: 150, width: 150,
sorter: true,
sortOrder: sortParams.value.field === 'author' ? sortParams.value.order : null,
onHeaderCell: () => ({
onClick: () => {
handleSortChange('author');
},
}),
}, },
{ {
title: '视频类型', title: '视频类型',
@@ -304,10 +329,32 @@ const columns = ref([
key: 'operation', key: 'operation',
align: 'center', align: 'center',
width: 180, 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) => { watch(isBatchMode, (isOpen) => {
if (!isOpen) { if (!isOpen) {
@@ -347,6 +394,8 @@ const quaryData: UnwrapRef<QuaryParam> = reactive({
viedoType: '*', viedoType: '*',
authorId: '', authorId: '',
fileHash: '', fileHash: '',
sortField: 'createTime', // 📌 默认排序字段
sortOrder: 'desc', // 📌 默认降序
}); });
// 分页配置 // 分页配置
@@ -438,6 +487,10 @@ const GetRecords = () => {
if (value2.value) { if (value2.value) {
quaryData.dates2 = value2.value.map((date) => date.format('YYYY-MM-DD')); // 修复:之前误写为value1 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() useApiStore()
.VideoPageList(quaryData) .VideoPageList(quaryData)
.then((res) => { .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 = () => { const StartNow = () => {
if (isSyncing.value) return; if (isSyncing.value) return;
@@ -496,15 +588,15 @@ const datePicked2 = (_, dateArry: RangeValue) => {
}; };
/** 表格分页/排序变化事件 */ /** 表格分页/排序变化事件 */
const handleTableChange = (paginationObj: any) => { // const handleTableChange = (paginationObj: any) => {
pagination.value.current = paginationObj.current; // pagination.value.current = paginationObj.current;
pagination.value.defaultPageSize = paginationObj.pageSize; // pagination.value.defaultPageSize = paginationObj.pageSize;
// 分页变化时清空选中状态(跨页不保留) // // 分页变化时清空选中状态(跨页不保留)
if (isBatchMode.value) { // if (isBatchMode.value) {
selectedRowKeys.value = []; // selectedRowKeys.value = [];
} // }
GetRecords(); // GetRecords();
}; // };
/** 视频类型切换事件 */ /** 视频类型切换事件 */
const onViedoTypeChanged = () => { const onViedoTypeChanged = () => {
@@ -1452,4 +1544,13 @@ onMounted(() => {
height: 24px !important; 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> </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() { async function VideoStatics() {
return http.request<any, Response<any>>('/api/video/statics', 'get').then(r => { return http.request<any, Response<any>>('/api/video/statics', 'get').then(r => {
return 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) { 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 }) { // async function apiUploadAudio(formData: FormData, options?: { onUploadProgress?: (progressEvent: ProgressEvent) => void }) {
@@ -362,6 +387,9 @@ export const useApiStore = defineStore('coreapi', () => {
// } // }
return { return {
VideoChart,
BatchSaveCate,
CatePageList,
getVer, getVer,
mp3List, mp3List,
BathRealDelete, 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> <HintPath>lib\dy.sync.lib.dll</HintPath>
</Reference> </Reference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish"> <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
<!-- As part of publishing, ensure the JS resources are freshly built in production mode --> <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
<!--<Exec WorkingDirectory="$(SpaRoot)" Command="yarn install" /> <!--<Exec WorkingDirectory="$(SpaRoot)" Command="yarn install" />
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup> <PropertyGroup>
<ActiveDebugProfile>tiny.ddns</ActiveDebugProfile> <ActiveDebugProfile>dy.net</ActiveDebugProfile>
<NameOfLastUsedPublishProfile>E:\gitea\dysync\Properties\PublishProfiles\docker.pubxml</NameOfLastUsedPublishProfile> <NameOfLastUsedPublishProfile>E:\code\dysync\Properties\PublishProfiles\docker.pubxml</NameOfLastUsedPublishProfile>
<Controller_SelectedScaffolderID>ApiControllerEmptyScaffolder</Controller_SelectedScaffolderID> <Controller_SelectedScaffolderID>ApiControllerEmptyScaffolder</Controller_SelectedScaffolderID>
<Controller_SelectedScaffolderCategoryPath>root/Common/Api</Controller_SelectedScaffolderCategoryPath> <Controller_SelectedScaffolderCategoryPath>root/Common/Api</Controller_SelectedScaffolderCategoryPath>
</PropertyGroup> </PropertyGroup>
+1
View File
@@ -220,6 +220,7 @@ namespace dy.net.extension
services.AddHttpClient(DouyinRequestParamManager.DY_HTTP_CLIENT, client => services.AddHttpClient(DouyinRequestParamManager.DY_HTTP_CLIENT, client =>
{ {
client.DefaultRequestHeaders.Add("User-Agent", DouyinRequestParamManager.DY_USER_AGENT); client.DefaultRequestHeaders.Add("User-Agent", DouyinRequestParamManager.DY_USER_AGENT);
client.BaseAddress = new Uri("https://www.douyin.com");
}).ConfigurePrimaryHttpMessageHandler(ignoreSslHandlerFactory); }).ConfigurePrimaryHttpMessageHandler(ignoreSslHandlerFactory);
// 抖音下载客户端(单独配置,保持原有特性) // 抖音下载客户端(单独配置,保持原有特性)
+98 -96
View File
@@ -5,105 +5,107 @@ using Serilog;
using static Quartz.Logging.OperationName; using static Quartz.Logging.OperationName;
namespace dy.net.job namespace dy.net.job
{ /// <summary>
/// 抖音任务依赖监听器(独立公共类)
/// 作用:监听任务执行完成事件,触发下一个依赖任务,实现顺序执行
/// </summary>
public class DouyinJobDependencyListener : IJobListener
{ {
// 监听器名称(唯一标识,不可重复)
public string Name => "DouyinJobDependencyListener";
/// <summary> // /// <summary>
/// 任务配置字典(从外部注入) ///// 抖音任务依赖监听器(独立公共类)
/// </summary> ///// 作用:监听任务执行完成事件,触发下一个依赖任务,实现顺序执行
private readonly Dictionary<string, JobConfig> _jobConfigs; ///// </summary>
// public class DouyinJobDependencyListener : IJobListener
// {
// // 监听器名称(唯一标识,不可重复)
// public string Name => "DouyinJobDependencyListener";
/// <summary> // /// <summary>
/// 任务依赖关系(从外部注入,定义执行顺序) // /// 任务配置字典(从外部注入)
/// </summary> // /// </summary>
private readonly Dictionary<string, string> _jobDependency; // private readonly Dictionary<string, JobConfig> _jobConfigs;
/// <summary> // /// <summary>
/// 任务服务(用于触发下一个任务,从外部注入) // /// 任务依赖关系(从外部注入,定义执行顺序)
/// </summary> // /// </summary>
private readonly DouyinQuartzJobService _jobService; // private readonly Dictionary<string, string> _jobDependency;
/// <summary> // /// <summary>
/// 构造函数(依赖注入) // /// 任务服务(用于触发下一个任务,从外部注入)
/// </summary> // /// </summary>
/// <param name="jobConfigs">任务配置</param> // private readonly DouyinQuartzJobService _jobService;
/// <param name="jobDependency">任务依赖关系</param>
/// <param name="jobService">任务服务</param> // /// <summary>
public DouyinJobDependencyListener( // /// 构造函数(依赖注入)
Dictionary<string, JobConfig> jobConfigs, // /// </summary>
Dictionary<string, string> jobDependency, // /// <param name="jobConfigs">任务配置</param>
DouyinQuartzJobService jobService) // /// <param name="jobDependency">任务依赖关系</param>
{ // /// <param name="jobService">任务服务</param>
_jobConfigs = jobConfigs ?? throw new ArgumentNullException(nameof(jobConfigs), "任务配置不能为空"); // public DouyinJobDependencyListener(
_jobDependency = jobDependency ?? throw new ArgumentNullException(nameof(jobDependency), "任务依赖关系不能为空"); // Dictionary<string, JobConfig> jobConfigs,
_jobService = jobService ?? throw new ArgumentNullException(nameof(jobService), "任务服务不能为空"); // Dictionary<string, string> jobDependency,
} // DouyinQuartzJobService jobService)
// {
/// <summary> // _jobConfigs = jobConfigs ?? throw new ArgumentNullException(nameof(jobConfigs), "任务配置不能为空");
/// 任务执行前触发(无需处理) // _jobDependency = jobDependency ?? throw new ArgumentNullException(nameof(jobDependency), "任务依赖关系不能为空");
/// </summary> // _jobService = jobService ?? throw new ArgumentNullException(nameof(jobService), "任务服务不能为空");
public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) // }
{
return Task.CompletedTask; // /// <summary>
} // /// 任务执行前触发(无需处理)
// /// </summary>
/// <summary> // public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default)
/// 任务被否决执行时触发(无需处理) // {
/// </summary> // return Task.CompletedTask;
public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default) // }
{
return Task.CompletedTask; // /// <summary>
} // /// 任务被否决执行时触发(无需处理)
// /// </summary>
/// <summary> // public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default)
/// 任务执行完成后触发(核心逻辑:触发下一个依赖任务) // {
/// </summary> // return Task.CompletedTask;
public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default) // }
{
var currentJobKey = context.JobDetail.Key; // /// <summary>
Log.Information("【任务监听】任务执行完成 - 任务名称: {JobName}, 执行状态: {Status}", // /// 任务执行完成后触发(核心逻辑:触发下一个依赖任务)
currentJobKey.Name, jobException == null ? "成功" : "失败"); // /// </summary>
// public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default)
// 1. 若当前任务执行失败,终止后续依赖任务(避免无效执行) // {
if (jobException != null) // var currentJobKey = context.JobDetail.Key;
{ // Log.Information("【任务监听】任务执行完成 - 任务名称: {JobName}, 执行状态: {Status}",
Log.Error(jobException, "【任务监听】任务 {JobName} 执行失败,终止后续任务链条", currentJobKey.Name); // currentJobKey.Name, jobException == null ? "成功" : "失败");
return;
} // // 1. 若当前任务执行失败,终止后续依赖任务(避免无效执行)
// if (jobException != null)
// 2. 根据当前任务的 JobKey,找到对应的配置 Key(如:dy.job.key.collect → collect) // {
var currentConfigKey = _jobConfigs.FirstOrDefault(kv => kv.Value.JobKey == currentJobKey.Name).Key; // Log.Error(jobException, "【任务监听】任务 {JobName} 执行失败,终止后续任务链条", currentJobKey.Name);
if (string.IsNullOrEmpty(currentConfigKey)) // return;
{ // }
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))
// 3. 查找下一个依赖任务的配置 Key // {
if (!_jobDependency.TryGetValue(currentConfigKey, out var nextConfigKey) || string.IsNullOrEmpty(nextConfigKey)) // Log.Warning("【任务监听】未找到任务 {JobName} 的配置信息,任务链条终止", currentJobKey.Name);
{ // return;
Log.Information("【任务监听】任务 {JobName} 是最后一个任务,本次任务链条执行完毕", currentJobKey.Name); // }
return;
} // // 3. 查找下一个依赖任务的配置 Key
// if (!_jobDependency.TryGetValue(currentConfigKey, out var nextConfigKey) || string.IsNullOrEmpty(nextConfigKey))
// 4. 触发下一个任务(标记为「依赖触发」,立即执行) // {
Log.Information("【任务监听】准备触发下一个任务: {NextJobName}(依赖触发)", nextConfigKey); // Log.Information("【任务监听】任务 {JobName} 是最后一个任务,本次任务链条执行完毕", currentJobKey.Name);
var triggerSuccess = await _jobService.StartJobAsync(nextConfigKey, "", isDependencyTrigger: true); // return;
// }
if (triggerSuccess)
{ // // 4. 触发下一个任务(标记为「依赖触发」,立即执行)
Log.Information("【任务监听】下一个任务 {NextJobName} 触发成功", nextConfigKey); // Log.Information("【任务监听】准备触发下一个任务: {NextJobName}(依赖触发)", nextConfigKey);
} // var triggerSuccess = await _jobService.StartJobAsync(nextConfigKey, "", isDependencyTrigger: true);
else
{ // if (triggerSuccess)
Log.Error("【任务监听】下一个任务 {NextJobName} 触发失败,任务链条中断", nextConfigKey); // {
} // Log.Information("【任务监听】下一个任务 {NextJobName} 触发成功", nextConfigKey);
} // }
} // else
// {
// Log.Error("【任务监听】下一个任务 {NextJobName} 触发失败,任务链条中断", nextConfigKey);
// }
// }
// }
} }
+18 -12
View File
@@ -3,6 +3,7 @@ using dy.net.model.entity;
using dy.net.model.response; using dy.net.model.response;
using dy.net.service; using dy.net.service;
using dy.net.utils; using dy.net.utils;
using Serilog;
using System; using System;
namespace dy.net.job 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) 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.SyncCollectVideos(cursor, count, cookie.Cookies);
@@ -35,27 +41,27 @@ namespace dy.net.job
return data != null && data.HasMore == 1 && cookie.CollHasSyncd == 0; 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; cookie.CollHasSyncd = 1;
await douyinCookieService.UpdateAsync(cookie); await douyinCookieService.UpdateAsync(cookie);
} Log.Debug($"[{VideoType}]-[{cookie.UserName}],本次成功同步{syncCount}条视频");
else
{
Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],没有可以同步的新视频");
}
} }
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate) protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed, DouyinCollectCate cate)
{ {
var (tag1, _, _) = GetVideoTags(item); string authorFolder;
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizeLinuxFileName(tag1, "", true); if (string.IsNullOrWhiteSpace(item.Author?.Nickname) && string.IsNullOrWhiteSpace(item.Author?.Uid))
var folder = Path.Combine(cookie.SavePath, safeTag1, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true)}"); {
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); if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder; return folder;
+17 -12
View File
@@ -15,6 +15,11 @@ namespace dy.net.job
protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_favorite; 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() protected override async Task<List<DouyinCookie>> GetSyncCookies()
{ {
return await douyinCookieService.GetOpendCookiesAsync(x=> !string.IsNullOrWhiteSpace(x.FavSavePath)&&!string.IsNullOrWhiteSpace(x.SecUserId)); 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; cookie.FavHasSyncd = 1;
await douyinCookieService.UpdateAsync(cookie); await douyinCookieService.UpdateAsync(cookie);
} await base.HandleSyncCompletion(cookie, syncCount, followed, cate);
else
{
Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],没有可以同步的新视频");
}
} }
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed,DouyinCollectCate cate) protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed,DouyinCollectCate cate)
{ {
var (tag1, _, _) = GetVideoTags(item); string authorFolder;
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizeLinuxFileName(tag1,"",true); if (string.IsNullOrWhiteSpace(item.Author?.Nickname) && string.IsNullOrWhiteSpace(item.Author?.Uid))
var folder = Path.Combine(cookie.FavSavePath, safeTag1, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId,true)}"); {
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); if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder; return folder;
} }
+254 -205
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 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) 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) 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 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; return data != null && data.HasMore == 1 && cookie.UperSyncd == 0 && followed.FullSync;
} }
protected override string GetAuthorAvatarBasePath(DouyinCookie cookie)
{
return Path.Combine(cookie.UpSavePath, "author");
}
/// <summary> /// <summary>
/// 关注用户特殊处理文件夹存储路径,用户可自定义保存路径 /// 关注用户特殊处理文件夹存储路径,用户可自定义保存路径
@@ -41,6 +44,7 @@ namespace dy.net.job
/// <param name="cookie"></param> /// <param name="cookie"></param>
/// <param name="item"></param> /// <param name="item"></param>
/// <param name="followed"></param> /// <param name="followed"></param>
/// <param name="cate"></param>
/// <param name="config"></param> /// <param name="config"></param>
/// <returns></returns> /// <returns></returns>
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed,DouyinCollectCate cate) 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 > 默认值) // 1. 优先获取有效的作者名称(遵循原有优先级:followed.UperName > item.Author.Nickname > 默认值)
var rawAuthorName = followed?.UperName ?? item?.Author?.Nickname; var rawAuthorName = followed?.UperName ?? item?.Author?.Nickname;
var authorName = string.IsNullOrWhiteSpace(rawAuthorName) var authorName = string.IsNullOrWhiteSpace(rawAuthorName)
? "UnknownAuthor" ? "未知博主"
: DouyinFileNameHelper.SanitizeLinuxFileName(rawAuthorName, "", true); : DouyinFileNameHelper.SanitizeLinuxFileName(rawAuthorName, "", true);
// 2. 确定最终文件夹路径(遵循原有优先级:followed.SavePath > authorName > 基础路径) // 2. 确定最终文件夹路径(遵循原有优先级:followed.SavePath > authorName > 基础路径)
var targetFolderName = !string.IsNullOrWhiteSpace(followed?.SavePath) ? followed.SavePath : authorName; var targetFolderName = !string.IsNullOrWhiteSpace(followed?.SavePath) ? followed.SavePath : authorName;
@@ -58,26 +62,20 @@ namespace dy.net.job
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
#endregion #endregion
if (config.UperSaveTogether)
{
return folder;
}
else
{
var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true); var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId, true);
var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result; var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName);
var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName; var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName;
return Path.Combine(folder, fileNameFolder); return Path.Combine(folder, fileNameFolder);
} }
}
/// <summary> /// <summary>
/// 关注的视频,生成文件名称 /// 关注的视频,生成文件名称
/// </summary> /// </summary>
/// <param name="cookie"></param> /// <param name="cookie"></param>
/// <param name="item"></param> /// <param name="item"></param>
/// <param name="config"></param> /// <param name="config"></param>
/// <param name="cate"></param>
/// <returns></returns> /// <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"; string Format = "mp4";
@@ -109,7 +107,7 @@ namespace dy.net.job
if (config?.UperUseViedoTitle ?? false)//优先 if (config?.UperUseViedoTitle ?? false)//优先
{ {
var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId); 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}"; fileName = string.IsNullOrWhiteSpace(existingName) ? $"{sampleName}.{Format}" : $"{existingName}.{Format}";
} }
else else
@@ -146,47 +144,20 @@ namespace dy.net.job
/// <param name="item"></param> /// <param name="item"></param>
/// <param name="config"></param> /// <param name="config"></param>
/// <param name="imageType"></param> /// <param name="imageType"></param>
/// <param name="cate"></param>
/// <returns></returns> /// <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) return base.GetNfoFileName(cookie, item, config, imageType, cate);
{
var videoFileName = GetVideoFileName(cookie, item,config);
return $"{Path.GetFileNameWithoutExtension(videoFileName)}{imageType}";
}
else
{
return base.GetNfoFileName(cookie, item, config, imageType);
}
} }
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; cookie.UperSyncd = 1;
await douyinCookieService.UpdateAsync(cookie); await douyinCookieService.UpdateAsync(cookie);
} await base.HandleSyncCompletion(cookie, syncCount, followed, cate);
else
{
Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}]-{(followed == null ? "" : $"{followed.UperName}")},没有可以同步的新视频");
}
} }
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
};
}
} }
} }
+119 -204
View File
@@ -3,45 +3,25 @@ using dy.net.model.entity;
using dy.net.model.response; using dy.net.model.response;
using dy.net.service; using dy.net.service;
using Quartz; using Quartz;
using Serilog;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace dy.net.job namespace dy.net.job
{ {
[DisallowConcurrentExecution] // 禁止并发执行,确保同一时间只有一个实例在运行 [DisallowConcurrentExecution]
public class DouyinFollowsAndCollnectsSyncJob : IJob 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 string DEFAULT_FOLLOW_COUNT = "20";
private const int INVALID_COOKIE_STATUS_CODE = 8; private const int INVALID_COOKIE_STATUS_CODE = 8;
private const string LOG_TAG_COLLECT = "收藏夹同步"; 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_SERIES = "短剧列表同步";
private const string LOG_TAG_FOLLOW = "关注列表同步"; private const string LOG_TAG_FOLLOW = "关注列表同步";
/// <summary> // 构造函数注入
/// 抖音数据同步任务执行方法(优化版) public DouyinFollowsAndCollnectsSyncJob(
/// </summary> DouyinCookieService dyCookieService,
/// <param name="context">任务执行上下文</param> DouyinHttpClientService douyinService,
DouyinFollowService followService,
DouyinCommonService douyinCommonService,
DouyinCollectCateService douyinCollectCateService)
{
_dyCookieService = dyCookieService;
_douyinService = douyinService;
_followService = followService;
_douyinCommonService = douyinCommonService;
_douyinCollectCateService = douyinCollectCateService;
}
public async Task Execute(IJobExecutionContext context) public async Task Execute(IJobExecutionContext context)
{ {
// 1. 获取有效Cookie列表,提前做空值判断
var cookies = await _dyCookieService.GetOpendCookiesAsync(); var cookies = await _dyCookieService.GetOpendCookiesAsync();
if (cookies == null || !cookies.Any()) if (cookies == null || !cookies.Any())
{ {
Serilog.Log.Debug("当前无可用的抖音Cookie,同步任务跳过"); Log.Debug("当前无可用的抖音Cookie,同步任务跳过");
return; return;
} }
// 2. 获取应用配置 var conf = _douyinCommonService.GetConfig();
AppConfig conf = douyinCommonService.GetConfig();
// 3. 遍历每个Cookie,执行各类数据同步
foreach (var ck in cookies) 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) if (ck.UseCollectFolder)
{ {
await SyncCollectDataAsync( await SyncCollectGenericAsync(ck, VideoTypeEnum.dy_custom_collect,
cookie: ck, (cookie, offset) => _douyinService.SyncCollectFolderList(cookie.Cookies, offset),
cateType: VideoTypeEnum.dy_custom_collect, item => new DouyinCollectCate
dataFetchFunc: async (offset) => await _douyinService.SyncCollectFolderList(ck.Cookies, offset),
entityConvertFunc: (collectItem) => new DouyinCollectCate
{ {
CookieId = ck.Id, CookieId = ck.Id,
CateType = VideoTypeEnum.dy_custom_collect, CateType = VideoTypeEnum.dy_custom_collect,
Name = collectItem.CollectsName, Name = item.CollectsName,
Sync = false, Sync = false,
XId = collectItem.CollectsId XId = item.CollectsId,
Total = item.TotalNumber
}, },
hasMoreFunc: (data) => data?.HasMore ?? false, data => data?.HasMore ?? false,
cursorFunc: (data) => data?.Cursor.ToString() ?? "0", data => data?.Cursor.ToString() ?? "0",
dataListFunc: (data) => data?.CollectsList, data => data?.CollectsList,
logTag: LOG_TAG_COLLECT LOG_TAG_COLLECT);
);
} }
// 3.2 同步收藏夹合集(DownMix=true时)
if (ck.DownMix) if (ck.DownMix)
{ {
await SyncCollectDataAsync( await SyncCollectGenericAsync(ck, VideoTypeEnum.dy_mix,
cookie: ck, (cookie, offset) => _douyinService.SyncMixList(cookie.Cookies, offset),
cateType: VideoTypeEnum.dy_mix, item => new DouyinCollectCate
dataFetchFunc: async (offset) => await _douyinService.SyncMixList(ck.Cookies, offset),
entityConvertFunc: (mixItem) => new DouyinCollectCate
{ {
CookieId = ck.Id, CookieId = ck.Id,
CateType = VideoTypeEnum.dy_mix, CateType = VideoTypeEnum.dy_mix,
Name = mixItem.MixName, Name = item.MixName,
Sync = false, Sync = false,
XId = mixItem.MixId, XId = item.MixId,
CoverUrl = mixItem.CoverUrl?.UrlList?.FirstOrDefault() // 空值防护:避免CoverUrl为null Total = item?.Statis?.UpdatedToEpisode ?? 0,
CoverUrl = item.CoverUrl?.UrlList?.LastOrDefault()
}, },
hasMoreFunc: (data) => (data?.HasMore ?? 0) == 1, data => (data?.HasMore ?? 0) == 1,
cursorFunc: (data) => data?.Cursor.ToString() ?? "0", data => data?.Cursor.ToString() ?? "0",
dataListFunc: (data) => data?.MixInfos, data => data?.MixInfos,
logTag: LOG_TAG_MIX LOG_TAG_MIX);
);
} }
// 3.3 同步收藏夹短剧(DownSeries=true时)
if (ck.DownSeries) if (ck.DownSeries)
{ {
await SyncCollectDataAsync( await SyncCollectGenericAsync(ck, VideoTypeEnum.dy_series,
cookie: ck, (cookie, offset) => _douyinService.SyncSeriesList(cookie.Cookies, offset),
cateType: VideoTypeEnum.dy_series, item => new DouyinCollectCate
dataFetchFunc: async (offset) => await _douyinService.SyncSeriesList(ck.Cookies, offset),
entityConvertFunc: (seriesItem) => new DouyinCollectCate
{ {
CookieId = ck.Id, CookieId = ck.Id,
CateType = VideoTypeEnum.dy_series, CateType = VideoTypeEnum.dy_series,
Name = seriesItem.SeriesName, Name = item.SeriesName,
Sync = false, Sync = false,
XId = seriesItem.SeriesId, XId = item.SeriesId,
CoverUrl = seriesItem.CoverImage?.ImageUrlList?.FirstOrDefault() // 空值防护:避免CoverImage为null Total = item?.Stats?.TotalEpisodeCount ?? 0,
CoverUrl = item.CoverImage?.ImageUrlList?.FirstOrDefault()
}, },
hasMoreFunc: (data) => (data?.HasMore ?? 0) == 1, data => (data?.HasMore ?? 0) == 1,
cursorFunc: (data) => data?.Cursor.ToString() ?? "0", data => data?.Cursor.ToString() ?? "0",
dataListFunc: (data) => data?.SeriesList, data => data?.SeriesList,
logTag: LOG_TAG_SERIES LOG_TAG_SERIES);
);
} }
// 3.4 同步关注列表(单独处理,逻辑特殊不纳入通用方法)
await SyncFollowListAsync(ck, conf); await SyncFollowListAsync(ck, conf);
Log.Debug($"完成[{ck.UserName}] [列表]同步, 包括 [自定义收藏夹、关注、合集、短剧] ");
Serilog.Log.Debug($"完成Cookie用户:[{ck.UserName}](ID:{ck.Id})的所有收藏夹信息、合集信息、关注列表信息、短剧信息同步");
} }
// 4. 标记首次运行完成(仅执行一次) await _douyinCommonService.SetConfigNotFirstRunning();
await douyinCommonService.SetConfigNotFirstRunning();
} }
#region 私有通用辅助方法 #region 通用核心方法(无冗余,支持调试)
/// <summary> /// <summary>
/// 通用收藏类数据同步方法(泛型封装,消除冗余) /// 通用收藏类数据同步方法(无复杂委托,参数直观,便于调试)
/// </summary> /// </summary>
/// <typeparam name="TData">抖音返回的分页数据类型</typeparam> /// <typeparam name="TData">分页数据类型</typeparam>
/// <typeparam name="TItem">抖音返回的列表项类型</typeparam> /// <typeparam name="TItem">列表项类型</typeparam>
/// <param name="cookie">当前Cookie信息</param> private async Task SyncCollectGenericAsync<TData, TItem>(
/// <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>(
DouyinCookie cookie, DouyinCookie cookie,
VideoTypeEnum cateType, VideoTypeEnum cateType,
Func<string, Task<TData>> dataFetchFunc, // 数据获取方法(最简委托,仅传必要参数)
Func<DouyinCookie, string, Task<TData>> dataFetchFunc,
// 实体转换方法(内联lambda,调试可直接看到转换逻辑)
Func<TItem, DouyinCollectCate> entityConvertFunc, Func<TItem, DouyinCollectCate> entityConvertFunc,
// 分页判断方法
Func<TData, bool> hasMoreFunc, Func<TData, bool> hasMoreFunc,
Func<TData, string> cursorFunc, // 游标获取方法
Func<TData, List<TItem>> dataListFunc, Func<TData, string> getCursorFunc,
// 列表提取方法
Func<TData, List<TItem>> getDataListFunc,
string logTag) string logTag)
{ {
var collectDataList = new List<DouyinCollectCate>();
string offset = "0"; string offset = "0";
bool hasMore = true; bool hasMore = true;
List<DouyinCollectCate> collectDataList = new List<DouyinCollectCate>();
try try
{ {
// 1. 分页获取抖音数据
while (hasMore) while (hasMore)
{ {
var pageData = await dataFetchFunc(offset); // 1. 获取分页数据(直接调用,调试可断点到具体Service方法)
if (pageData == null) var pageData = await dataFetchFunc(cookie, offset);
{ if (pageData == null) break;
//Serilog.Log.Warning($"[{cookie.UserName}] - {logTag}:获取分页数据为空,停止分页");
break;
}
// 2. 更新分页状态 // 2. 更新分页状态(逻辑透明)
hasMore = hasMoreFunc(pageData); hasMore = hasMoreFunc(pageData);
offset = cursorFunc(pageData); offset = getCursorFunc(pageData);
// 3. 提取当前页列表数据并转换 // 3. 提取并转换数据(内联逻辑,调试可看每一项转换结果)
var currentPageItems = dataListFunc(pageData); var currentItems = getDataListFunc(pageData);
if (currentPageItems != null && currentPageItems.Any()) if (currentItems?.Any() == true)
{ {
var convertItems = currentPageItems.Select(entityConvertFunc).ToList(); collectDataList.AddRange(currentItems.Select(entityConvertFunc));
collectDataList.AddRange(convertItems); Log.Debug($"[{cookie.UserName}] - {logTag}:获取{currentItems.Count}条,累计{collectDataList.Count}条");
Serilog.Log.Debug($"[{cookie.UserName}] - {logTag}:获取到{currentPageItems.Count}条数据,累计{collectDataList.Count}条");
} }
} }
// 4. 调用Sync方法同步到数据库 // 4. 同步到数据库
if (collectDataList.Any()) if (collectDataList.Any())
{ {
var (add, update, delete, succ) = await douyinCollectCateService.Sync( var (add, update, delete, succ) = await _douyinCollectCateService.Sync(collectDataList, cookie.Id, cateType);
collectDataList, Log.Debug($"[{cookie.UserName}] - {logTag}:同步完成 新增:{add} 更新:{update} 删除:{delete} 成功:{succ}");
cookie.Id,
cateType);
Serilog.Log.Debug($"[{cookie.UserName}] - {logTag}:同步完成,新增{add}条,更新{update}条,删除{delete}条,是否成功:{succ}");
} }
else else
{ {
Serilog.Log.Debug($"[{cookie.UserName}] - {logTag}:无有效数据需要同步"); Log.Debug($"[{cookie.UserName}] - {logTag}:无有效数据");
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Serilog.Log.Error(ex, $"[{cookie.UserName}] - {logTag}:分页同步失败,异常信息:{ex.Message}"); Log.Error(ex, $"[{cookie.UserName}] - {logTag}:同步失败");
} }
} }
#endregion
/// <summary> #region 关注列表同步(逻辑独立,无重复)
/// 关注列表单独同步方法(逻辑特殊,单独封装)
/// </summary>
/// <param name="cookie">当前Cookie信息</param>
/// <param name="config">应用配置</param>
private async Task SyncFollowListAsync(DouyinCookie cookie, AppConfig config) private async Task SyncFollowListAsync(DouyinCookie cookie, AppConfig config)
{ {
// 1. 校验SecUserId是否有效
if (string.IsNullOrWhiteSpace(cookie.SecUserId)) if (string.IsNullOrWhiteSpace(cookie.SecUserId))
{ {
Serilog.Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:未设置SecUserId,跳过"); Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:未设置SecUserId,跳过");
return; return;
} }
var followList = new List<FollowingsItem>();
string offset = "0"; string offset = "0";
bool hasMore = true; bool hasMore = true;
int total = 0; int total = 0;
List<FollowingsItem> followList = new List<FollowingsItem>(); FollowErrorDto currentError = null;
try try
{ {
// 2. 分页获取关注列表数据
while (hasMore) while (hasMore)
{ {
var (data, error) = await FetchFollowPageDataAsync(cookie, offset); var data = await _douyinService.SyncMyFollows(
DEFAULT_FOLLOW_COUNT, offset, cookie.SecUserId, cookie.Cookies,
// 3. 处理错误信息 async (err) =>
if (error != null && error.StatusCode != 0)
{ {
Serilog.Log.Error($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:发生错误,状态码:{error.StatusCode},错误信息:{error.StatusMsg}"); currentError = err;
// 无效Cookie(未登录)直接停止分页 cookie.StatusMsg = err.StatusCode == INVALID_COOKIE_STATUS_CODE ? "无效" : "正常";
if (error.StatusCode == INVALID_COOKIE_STATUS_CODE) cookie.StatusCode = err.StatusCode;
{
cookie.StatusMsg = "无效";
cookie.StatusCode = INVALID_COOKIE_STATUS_CODE;
await _dyCookieService.UpdateAsync(cookie); await _dyCookieService.UpdateAsync(cookie);
break; });
}
if (currentError != null && currentError.StatusCode != 0)
{
Log.Error($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:错误 状态码:{currentError.StatusCode} 信息:{currentError.StatusMsg}");
if (currentError.StatusCode == INVALID_COOKIE_STATUS_CODE) break;
} }
// 4. 处理有效数据 if (data == null) break;
if (data != null)
{
// 5. 更新MyUserId(若未设置)
if (string.IsNullOrWhiteSpace(cookie.MyUserId)) if (string.IsNullOrWhiteSpace(cookie.MyUserId))
{ {
cookie.MyUserId = data.MySelfUserId; cookie.MyUserId = data.MySelfUserId;
await _dyCookieService.UpdateAsync(cookie); await _dyCookieService.UpdateAsync(cookie);
} }
// 6. 更新分页状态和数据
total = data.Total; total = data.Total;
hasMore = data.HasMore; hasMore = data.HasMore;
offset = data.Offset.ToString(); offset = data.Offset.ToString();
if (data.Followings != null && data.Followings.Any()) if (data.Followings?.Any() == true)
{ {
followList.AddRange(data.Followings); followList.AddRange(data.Followings);
Serilog.Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:获取到{data.Followings.Count}条关注数据,累计{followList.Count}条"); Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:获取{data.Followings.Count}条,累计{followList.Count}条");
} }
// 7. 非首次运行时,仅同步第一页数据 if (!config.IsFirstRunning) hasMore = false;
if (!config.IsFirstRunning)
{
hasMore = false;
//Serilog.Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:非首次运行,停止分页获取");
}
}
else
{
Serilog.Log.Warning($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:获取分页数据为空,停止分页");
break;
}
} }
// 8. 同步关注列表到数据库
if (followList.Any()) if (followList.Any())
{ {
var (add, update, succ) = await _followService.Sync(followList, cookie); var (add, update, succ) = await _followService.Sync(followList, cookie);
Serilog.Log.Information($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:同步完成,新增{add}条,更新{update}条,是否成功:{succ},总关注数:{total}"); Log.Information($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:同步完成 新增:{add} 更新:{update} 成功:{succ} 总关注数:{total}");
}
else
{
Serilog.Log.Debug($"[{cookie.UserName}] - {LOG_TAG_FOLLOW}:无有效关注数据需要同步");
} }
} }
catch (Exception ex) 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 #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) 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) 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; 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? 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 VideoFollowSize { get; set; }
public string VideoFavoriteSize { get; set; } public string VideoFavoriteSize { get; set; }
public string VideoMixSize { get; set; }
public string VideoSeriesSize { get; set; }
public long CollectCount { get; set; } public long CollectCount { get; set; }
public long FavoriteCount { get; set; } public long FavoriteCount { get; set; }
public long FollowCount { get; set; } public long FollowCount { get; set; }
public long MixCount { get; set; }
public long SeriesCount { get; set; }
public long GraphicVideoCount { get; set; } public long GraphicVideoCount { get; set; }
public string GraphicVideoSize { get; set; } public string GraphicVideoSize { get; set; }
+2 -2
View File
@@ -33,7 +33,7 @@ namespace dy.net.model.entity
/// <summary> /// <summary>
/// 是否博主视频直接放一个根目录,不另外按名字建文件夹 /// 是否博主视频直接放一个根目录,不另外按名字建文件夹
/// </summary> /// </summary>
public bool UperSaveTogether { get; set; } //public bool UperSaveTogether { get; set; }
/// <summary> /// <summary>
/// 是否下载图片视频 /// 是否下载图片视频
/// </summary> /// </summary>
@@ -68,7 +68,7 @@ namespace dy.net.model.entity
/// <summary> /// <summary>
/// 图文视频是否单独存放,否的话则按原类型存储位置存放,比如收藏夹、喜欢等 /// 图文视频是否单独存放,否的话则按原类型存储位置存放,比如收藏夹、喜欢等
/// </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>
/// 封面 /// 封面
/// </summary> /// </summary>
[SugarColumn(Length =500,IsNullable =true)]
public string CoverUrl { get; set; } public string CoverUrl { get; set; }
/// <summary> /// <summary>
/// 保存文件夹 /// 保存文件夹
/// </summary> /// </summary>
[SugarColumn(Length =500,IsNullable =true)]
public string SaveFolder { get; set; } public string SaveFolder { get; set; }
/// <summary> /// <summary>
/// 是否开启同步 /// 是否开启同步
@@ -52,10 +54,16 @@ namespace dy.net.model.entity
public DateTime CreateTime { get; set; } public DateTime CreateTime { get; set; }
public DateTime UpdateTime { get; set; } [SugarColumn(IsNullable =true)]
public DateTime? UpdateTime { get; set; }
/// <summary> /// <summary>
/// 是否已完结 /// 是否已完结
/// </summary> /// </summary>
public bool IsEnd { get; set; } public bool IsEnd { get; set; }
/// <summary>
/// 总集数
/// </summary>
public int Total { get; set; }
} }
} }
+15 -3
View File
@@ -95,8 +95,8 @@ namespace dy.net.model.entity
/// <summary> /// <summary>
/// 图片视频存储路径 /// 图片视频存储路径
/// </summary> /// </summary>
[SugarColumn(Length = 500, IsNullable = true)] //[SugarColumn(Length = 500, IsNullable = true)]
public string ImgSavePath { get; set; } //public string ImgSavePath { get; set; }
/// <summary> /// <summary>
/// 抖音返回的状态码,主要用于判断Cookie是否有效 /// 抖音返回的状态码,主要用于判断Cookie是否有效
@@ -109,11 +109,23 @@ namespace dy.net.model.entity
/// </summary> /// </summary>
[SugarColumn(Length = 100, IsNullable = true)] [SugarColumn(Length = 100, IsNullable = true)]
public string StatusMsg { get; set; } public string StatusMsg { get; set; }
/// <summary> /// <summary>
/// 是否统一一个路径(savepath) /// 是否统一一个路径(savepath)
/// </summary> /// </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> /// </summary>
[SugarColumn(Length =200,IsNullable =true)] [SugarColumn(Length =200,IsNullable =true)]
public string CateId { get; set; } 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")] //[JsonProperty("share_info")]
//public DouyinShareInfo ShareInfo { get; set; } //public DouyinShareInfo ShareInfo { get; set; }
///// <summary> /// <summary>
///// 统计信息 /// 统计信息
///// </summary> /// </summary>
//[JsonProperty("statis")] [JsonProperty("statis")]
//public DouyinMixStatis Statis { get; set; } public DouyinMixStatis Statis { get; set; }
///// <summary> ///// <summary>
///// 状态信息 ///// 状态信息
+17 -17
View File
@@ -167,8 +167,8 @@ namespace dy.net.model.response
//[JsonProperty("share_info")] //[JsonProperty("share_info")]
//public DouyinDramaShareInfo ShareInfo { get; set; } //public DouyinDramaShareInfo ShareInfo { get; set; }
//[JsonProperty("stats")] [JsonProperty("stats")]
//public DouyinDramaStats Stats { get; set; } public DouyinDramaStats Stats { get; set; }
//[JsonProperty("status")] //[JsonProperty("status")]
//public DouyinDramaStatus DramaStatus { get; set; } //public DouyinDramaStatus DramaStatus { get; set; }
@@ -1087,26 +1087,26 @@ namespace dy.net.model.response
/// <summary> /// <summary>
/// 抖音短剧统计信息 /// 抖音短剧统计信息
/// </summary> /// </summary>
//public class DouyinDramaStats public class DouyinDramaStats
//{ {
// [JsonProperty("collect_vv")] [JsonProperty("collect_vv")]
// public int CollectViewCount { get; set; } public int CollectViewCount { get; set; }
// [JsonProperty("current_episode")] [JsonProperty("current_episode")]
// public int CurrentEpisode { get; set; } public int CurrentEpisode { get; set; }
// [JsonProperty("last_added_item_time")] [JsonProperty("last_added_item_time")]
// public long LastEpisodeAddTimestamp { get; set; } public long LastEpisodeAddTimestamp { get; set; }
// [JsonProperty("play_vv")] [JsonProperty("play_vv")]
// public long TotalPlayViewCount { get; set; } public long TotalPlayViewCount { get; set; }
// [JsonProperty("total_episode")] [JsonProperty("total_episode")]
// public int TotalEpisodeCount { get; set; } public int TotalEpisodeCount { get; set; }
// [JsonProperty("updated_to_episode")] [JsonProperty("updated_to_episode")]
// public int LatestEpisodeCount { get; set; } public int LatestEpisodeCount { get; set; }
//} }
/// <summary> /// <summary>
/// 抖音短剧状态信息 /// 抖音短剧状态信息
+23 -23
View File
@@ -3,7 +3,6 @@ using System.Collections.Generic;
namespace dy.net.model.response namespace dy.net.model.response
{ {
/// <summary> /// <summary>
/// 视频列表 /// 视频列表
/// </summary> /// </summary>
@@ -245,8 +244,8 @@ namespace dy.net.model.response
//[JsonProperty("media_type")] //[JsonProperty("media_type")]
//public int MediaType { get; set; } //public int MediaType { get; set; }
//[JsonProperty("mix_info")] [JsonProperty("mix_info")]
//public MixInfo MixInfo { get; set; } public MixInfo MixInfo { get; set; }
[JsonProperty("music")] [JsonProperty("music")]
public Music Music { get; set; } public Music Music { get; set; }
@@ -619,10 +618,10 @@ namespace dy.net.model.response
// public int Type { get; set; } // public int Type { get; set; }
//} //}
//public class MixInfo public class MixInfo
//{ {
// [JsonProperty("cover_url")] [JsonProperty("cover_url")]
// public ImageInfo CoverUrl { get; set; } public ImageInfo CoverUrl { get; set; }
//[JsonProperty("create_time")] //[JsonProperty("create_time")]
//public long CreateTime { get; set; } //public long CreateTime { get; set; }
@@ -660,8 +659,8 @@ namespace dy.net.model.response
//[JsonProperty("share_info")] //[JsonProperty("share_info")]
//public MixShareInfo ShareInfo { get; set; } //public MixShareInfo ShareInfo { get; set; }
// [JsonProperty("statis")] [JsonProperty("statis")]
// public MixStatis Statis { get; set; } public MixStatis Statis { get; set; }
//[JsonProperty("status")] //[JsonProperty("status")]
//public MixStatus Status { get; set; } //public MixStatus Status { get; set; }
@@ -671,7 +670,7 @@ namespace dy.net.model.response
//[JsonProperty("watched_item")] //[JsonProperty("watched_item")]
//public string WatchedItem { get; set; } //public string WatchedItem { get; set; }
//} }
//public class MixShareInfo //public class MixShareInfo
//{ //{
@@ -697,20 +696,20 @@ namespace dy.net.model.response
// public string ShareWeiboDesc { get; set; } // public string ShareWeiboDesc { get; set; }
//} //}
//public class MixStatis public class MixStatis
//{ {
// [JsonProperty("collect_vv")] [JsonProperty("collect_vv")]
// public int CollectVv { get; set; } public int CollectVv { get; set; }
// [JsonProperty("current_episode")] [JsonProperty("current_episode")]
// public int CurrentEpisode { get; set; } public int CurrentEpisode { get; set; }
// [JsonProperty("play_vv")] [JsonProperty("play_vv")]
// public int PlayVv { get; set; } public int PlayVv { get; set; }
// [JsonProperty("updated_to_episode")] [JsonProperty("updated_to_episode")]
// public int UpdatedToEpisode { get; set; } public int UpdatedToEpisode { get; set; }
//} }
//public class MixStatus //public class MixStatus
//{ //{
@@ -765,8 +764,8 @@ namespace dy.net.model.response
//[JsonProperty("collect_stat")] //[JsonProperty("collect_stat")]
//public int CollectStat { get; set; } //public int CollectStat { get; set; }
//[JsonProperty("cover_hd")] [JsonProperty("cover_hd")]
//public ImageInfo CoverHd { get; set; } public ImageInfo CoverHd { get; set; }
//[JsonProperty("cover_large")] //[JsonProperty("cover_large")]
//public ImageInfo CoverLarge { get; set; } //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.model.entity;
using dy.net.utils; using dy.net.utils;
using SqlSugar; using SqlSugar;
using System;
using System.Linq.Expressions; using System.Linq.Expressions;
namespace dy.net.repository namespace dy.net.repository
@@ -103,8 +104,11 @@ namespace dy.net.repository
// 用Dictionary查找,效率更高 // 用Dictionary查找,效率更高
if (dbCateDict.TryGetValue(cate.XId, out var existCate)) 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) if (isChanged)
{ {
@@ -131,7 +135,6 @@ namespace dy.net.repository
updateCount = await Db.Updateable(toUpdateCates) updateCount = await Db.Updateable(toUpdateCates)
.IgnoreColumns(x => new { x.Sync, x.CreateTime, x.CookieId }) .IgnoreColumns(x => new { x.Sync, x.CreateTime, x.CookieId })
.IgnoreNullColumns() // 忽略空值字段 .IgnoreNullColumns() // 忽略空值字段
.Where(c => c.Id == c.Id) // 批量更新默认按主键匹配,此处显式指定(也可省略,SqlSugar自动识别主键)
.ExecuteCommandAsync(); .ExecuteCommandAsync();
} }
@@ -154,12 +157,26 @@ namespace dy.net.repository
// 异常时回滚事务(补充:避免Db.Ado未初始化事务导致的空引用) // 异常时回滚事务(补充:避免Db.Ado未初始化事务导致的空引用)
await Db.Ado?.RollbackTranAsync(); await Db.Ado?.RollbackTranAsync();
isSuccess = false; isSuccess = false;
Serilog.Log.Error($"同步收藏夹:类型-{cateType}异常,{ex.StackTrace}"); Serilog.Log.Error($"同步{cateType.GetDesc()}时发生异常,{ex.StackTrace}");
} }
//Serilog.Log.Debug($"本次收藏夹{cateType}同步结果,新增:{addCount},更新:{updateCount},删除:{deleteCount}"); //Serilog.Log.Debug($"本次收藏夹{cateType}同步结果,新增:{addCount},更新:{updateCount},删除:{deleteCount}");
// 8. 返回最终结果 // 8. 返回最终结果
return (addCount, updateCount, deleteCount, isSuccess); 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 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()) if (list.Any())
{ {
var users = await this.Db.Queryable<DouyinCookie>().ToListAsync(); var users = await this.Db.Queryable<DouyinCookie>().ToListAsync();
@@ -93,12 +100,12 @@ namespace dy.net.repository
/// <param name="AuthorId"></param> /// <param name="AuthorId"></param>
/// <param name="ViedoNameSimplify"></param> /// <param name="ViedoNameSimplify"></param>
/// <returns></returns> /// <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) .Where(x => x.VideoTitleSimplify == ViedoNameSimplify)
.OrderByDescending(x => x.CreateTime).FirstAsync(); .OrderByDescending(x => x.CreateTime).First();
if (video != null) if (video != null)
{ {
+17 -7
View File
@@ -11,18 +11,18 @@ namespace dy.net.service
public class DouyinCollectCateService public class DouyinCollectCateService
{ {
private readonly DouyinCollectCateRepository douyinCollectCateRepository; private readonly DouyinCollectCateRepository _douyinCollectCateRepository;
public DouyinCollectCateService(DouyinCollectCateRepository douyinCollectCateRepository) public DouyinCollectCateService(DouyinCollectCateRepository douyinCollectCateRepository)
{ {
douyinCollectCateRepository = douyinCollectCateRepository; _douyinCollectCateRepository = douyinCollectCateRepository;
} }
public async Task<bool> AddAsync(DouyinCollectCate cate) 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> /// <returns></returns>
public async Task<(List<DouyinCollectCate> list, int totalCount)> GetPagedAsync(DouyinCollectCateRequestDto dto) public async Task<(List<DouyinCollectCate> list, int totalCount)> GetPagedAsync(DouyinCollectCateRequestDto dto)
{ {
return await douyinCollectCateRepository.GetPagedAsync(dto); return await _douyinCollectCateRepository.GetPagedAsync(dto);
} }
/// <summary> /// <summary>
@@ -47,7 +47,7 @@ namespace dy.net.service
/// <returns></returns> /// <returns></returns>
public async Task<(int add, int update,int delete, bool succ)> Sync(List<DouyinCollectCate> cates, string ckId,VideoTypeEnum cateType) 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> /// <returns></returns>
public async Task<bool> BatchSwitchSync(List<DouyinCollectCateSwitchDto> dto) 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> /// <returns></returns>
public async Task<List<DouyinCollectCate>> GetSyncCates(string cookieId, VideoTypeEnum cateType) 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, Cron = 30,
BatchCount = 18, BatchCount = 18,
LogKeepDay = 7, LogKeepDay = 7,
UperSaveTogether = false,//博主视频:true-->每个视频单独一个文件夹 false-->所有视频放在同一个文件夹 //UperSaveTogether = false,//博主视频:true-->每个视频单独一个文件夹 false-->所有视频放在同一个文件夹
UperUseViedoTitle = false,//博主视频:true-->使用视频标题作为文件名 false-->使用视频id作为文件名 UperUseViedoTitle = false,//博主视频:true-->使用视频标题作为文件名 false-->使用视频id作为文件名
DownImageVideo = true,//默认下载图文视频 DownImageVideo = true,//默认下载图文视频
DownMp3 = false, DownMp3 = false,
DownImage = false, DownImage = false,
ImageViedoSaveAlone = true,
FollowedTitleTemplate = "", FollowedTitleTemplate = "",
FullFollowedTitleTemplate = "", FullFollowedTitleTemplate = "",
FollowedTitleSeparator = "", FollowedTitleSeparator = "",
+3 -3
View File
@@ -53,14 +53,14 @@ namespace dy.net.service
var cookie = new DouyinCookie var cookie = new DouyinCookie
{ {
UserName = "douyin2026", UserName = "douyin2026",
Cookies = "-", Cookies = "",
SecUserId = "-", SecUserId = "",
Id = IdGener.GetLong().ToString(), Id = IdGener.GetLong().ToString(),
Status = 0, Status = 0,
SavePath = "/app/collect", SavePath = "/app/collect",
FavSavePath = "/app/favorite", FavSavePath = "/app/favorite",
UpSavePath = "/app/uper", UpSavePath = "/app/uper",
ImgSavePath="/app/images", //ImgSavePath="/app/images",
CollHasSyncd = 0, CollHasSyncd = 0,
FavHasSyncd = 0, FavHasSyncd = 0,
UperSyncd = 0, UperSyncd = 0,
+12 -11
View File
@@ -158,7 +158,7 @@ namespace dy.net.service
requestParameters["cursor"] = cursor; //页码 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) if (respose.IsSuccessStatusCode)
{ {
var data = await respose.Content.ReadAsStringAsync(); var data = await respose.Content.ReadAsStringAsync();
@@ -220,12 +220,13 @@ namespace dy.net.service
var requestParameters = DouyinRequestParamManager.DouyinFolderCollectParams; var requestParameters = DouyinRequestParamManager.DouyinFolderCollectParams;
{ {
count = "15";
requestParameters["cursor"] = cursor; requestParameters["cursor"] = cursor;
requestParameters["count"] = count; requestParameters["count"] = count;
requestParameters["collects_id"] = collectsId; 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) if (respose.IsSuccessStatusCode)
{ {
@@ -278,7 +279,7 @@ namespace dy.net.service
requestParameters["cursor"] = cursor; //页码 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) if (respose.IsSuccessStatusCode)
{ {
var data = await respose.Content.ReadAsStringAsync(); var data = await respose.Content.ReadAsStringAsync();
@@ -342,11 +343,11 @@ namespace dy.net.service
var requestParameters = DouyinRequestParamManager.DouyinMixVideoParams; var requestParameters = DouyinRequestParamManager.DouyinMixVideoParams;
{ {
requestParameters["cursor"] = cursor; requestParameters["cursor"] = cursor;
requestParameters["count"] = count; requestParameters["count"] = "15";
requestParameters["mix_id"] = mixId; 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) if (respose.IsSuccessStatusCode)
{ {
@@ -394,11 +395,11 @@ namespace dy.net.service
var requestParameters = DouyinRequestParamManager.DouyinSeriesListParams; var requestParameters = DouyinRequestParamManager.DouyinSeriesListParams;
{ {
// 添加动态参数 // 添加动态参数
requestParameters["count"] = "100"; requestParameters["count"] = "15";//固定15,多了直接返回参数不合法
requestParameters["cursor"] = cursor; //页码 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) if (respose.IsSuccessStatusCode)
{ {
var data = await respose.Content.ReadAsStringAsync(); var data = await respose.Content.ReadAsStringAsync();
@@ -466,7 +467,7 @@ namespace dy.net.service
requestParameters["series_id"] = seriesId; 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) if (respose.IsSuccessStatusCode)
{ {
@@ -538,7 +539,7 @@ namespace dy.net.service
requestParameters["sec_user_id"] = secUserId; requestParameters["sec_user_id"] = secUserId;
requestParameters["count"] = count; 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) if (respose.IsSuccessStatusCode)
{ {
@@ -610,7 +611,7 @@ namespace dy.net.service
requestParameters["sec_user_id"] = secUserId; requestParameters["sec_user_id"] = secUserId;
requestParameters["count"] = count; 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) if (respose.IsSuccessStatusCode)
{ {
@@ -683,7 +684,7 @@ namespace dy.net.service
requestParameters["offset"] = offset; 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) if (respose.IsSuccessStatusCode)
{ {
var data = await respose.Content.ReadAsStringAsync(); var data = await respose.Content.ReadAsStringAsync();
+72 -90
View File
@@ -1,9 +1,11 @@
using dy.net.job; using dy.net.job;
using dy.net.model.dto; using dy.net.model.dto;
using dy.net.utils;
using Quartz; using Quartz;
using Quartz.Impl.Matchers;
using Serilog; using Serilog;
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace dy.net.service namespace dy.net.service
@@ -19,19 +21,11 @@ namespace dy.net.service
private const int DefaultCronStartDelaySeconds = 30; private const int DefaultCronStartDelaySeconds = 30;
private const int DefaultSimpleStartDelaySeconds = 3; private const int DefaultSimpleStartDelaySeconds = 3;
// 任务顺序依赖配置(核心:定义执行顺序,供 Listener 使用) // 任务配置信息(修复了series任务的Key重复问题,确保每个任务Key唯一)
public Dictionary<string, string> JobDependency { get; } = new() public static Dictionary<string, JobConfig> JobConfigs { get; } = new()
{
{"collect", "favorite"}, // collect 执行完 → 触发 favorite
{"favorite", "followed"}, // favorite 执行完 → 触发 followed
{"followed", null}, // followed 执行完 → 一轮任务结束
};
// 任务配置信息(保持原有配置不变,改为 public 供 Listener 访问)
public Dictionary<string, JobConfig> JobConfigs { get; } = new()
{ {
{ {
"collect", VideoTypeEnum.dy_collects.GetDesc(),
new JobConfig( new JobConfig(
typeof(DouyinCollectSyncJob), typeof(DouyinCollectSyncJob),
"dy.job.key.collect", "dy.job.key.collect",
@@ -39,7 +33,7 @@ namespace dy.net.service
"抖音收藏同步任务") "抖音收藏同步任务")
}, },
{ {
"favorite", VideoTypeEnum.dy_favorite.GetDesc(),
new JobConfig( new JobConfig(
typeof(DouyinFavoritSyncJob), typeof(DouyinFavoritSyncJob),
"dy.job.key.favorite", "dy.job.key.favorite",
@@ -47,12 +41,12 @@ namespace dy.net.service
"抖音点赞同步任务") "抖音点赞同步任务")
}, },
{ {
"followed", VideoTypeEnum.dy_follows.GetDesc(),
new JobConfig( new JobConfig(
typeof(DouyinFollowedViedoSyncJob), typeof(DouyinFollowedSyncJob),
"dy.job.key.followed", "dy.job.key.followed",
"dy.trigger.key.followed", "dy.trigger.key.followed",
"抖音UP主作品同步任务") "抖音关注博主作品同步任务")
}, },
{ {
"follow_user", "follow_user",
@@ -60,7 +54,31 @@ namespace dy.net.service
typeof(DouyinFollowsAndCollnectsSyncJob), typeof(DouyinFollowsAndCollnectsSyncJob),
"dy.job.key.follow_user", "dy.job.key.follow_user",
"dy.trigger.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", "follow_user_once",
@@ -78,9 +96,9 @@ namespace dy.net.service
} }
/// <summary> /// <summary>
/// 启动所有抖音相关定时任务(顺序执行模式) /// 启动所有抖音相关定时任务(所有任务独立执行)
/// </summary> /// </summary>
/// <param name="expression">Cron表达式或间隔分钟数(控制整个链条的执行频率)</param> /// <param name="expression">Cron表达式或间隔分钟数(所有任务使用相同的执行频率)</param>
/// <returns>是否启动成功</returns> /// <returns>是否启动成功</returns>
public async Task<bool> InitOrReStartAllJobs(string expression) public async Task<bool> InitOrReStartAllJobs(string expression)
{ {
@@ -94,31 +112,37 @@ namespace dy.net.service
{ {
var scheduler = await _schedulerFactory.GetScheduler(); var scheduler = await _schedulerFactory.GetScheduler();
// 1. 注册独立的 JobListener(核心:注入配置和服务) // 移除所有已存在的任务(避免重复调度)
await RegisterJobListener(scheduler);
// 2. 移除所有已存在的任务(避免重复调度)
await RemoveAllExistingJobs(scheduler); await RemoveAllExistingJobs(scheduler);
// 3. 只启动第一个任务(collect),后续任务由 Listener 自动触发 // 遍历启动所有任务(独立执行,无顺序依赖)
var firstJobConfigKey = "collect"; var allJobKeys = JobConfigs.Keys.Where(k => k != "follow_user_once").ToList(); // 排除单次执行的任务
var startSuccess = await StartJobAsync(firstJobConfigKey, expression); foreach (var jobKey in allJobKeys)
{
if (jobKey == "follow_user")
{
expression = "60";
}
var startSuccess = await StartJobAsync(jobKey, expression);
if (startSuccess) if (startSuccess)
{ {
Log.Debug($"同步任务执行顺序:collect → favorite → followed-->默认每{expression}分钟执行一次..."); Log.Debug($"成功启动任务:{jobKey},执行频率:{expression}");
} }
else else
{ {
Log.Error($"任务执行失败(任务 {firstJobConfigKey} 启动失败)"); Log.Error($"启动任务失败:{jobKey}");
} }
//启动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) catch (Exception ex)
{ {
Log.Error(ex, "【任务服务】初始化任务链条异常"); Log.Error(ex, "【任务服务】初始化所有定时任务异常");
return false; return false;
} }
} }
@@ -131,27 +155,6 @@ namespace dy.net.service
return await StartOneTimeJobAsync("follow_user_once"); 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>
/// 移除所有已存在的任务(避免重复调度) /// 移除所有已存在的任务(避免重复调度)
/// </summary> /// </summary>
@@ -169,13 +172,12 @@ namespace dy.net.service
} }
/// <summary> /// <summary>
/// 启动指定定时任务(public 修饰,供 Listener 调用) /// 启动指定定时任务(独立执行,无依赖触发)
/// </summary> /// </summary>
/// <param name="configKey">任务配置Key(如:collect、favorite)</param> /// <param name="configKey">任务配置Key(如:collect、favorite)</param>
/// <param name="expression">定时表达式(依赖触发时传空)</param> /// <param name="expression">定时表达式(Cron或间隔分钟数)</param>
/// <param name="isDependencyTrigger">是否为依赖触发(true=立即执行,false=定时执行)</param>
/// <returns>是否启动成功</returns> /// <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)) if (!JobConfigs.TryGetValue(configKey, out var jobConfig))
{ {
@@ -187,33 +189,26 @@ namespace dy.net.service
{ {
var scheduler = await _schedulerFactory.GetScheduler(); var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup); var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup);
// 触发器Key:区分「定时触发」和「依赖触发」,避免冲突 var triggerKey = new TriggerKey(jobConfig.TriggerKey, DefaultJobGroup);
var triggerKey = new TriggerKey(
$"{jobConfig.TriggerKey}_{(isDependencyTrigger ? "dependency" : "main")}",
DefaultJobGroup
);
// 移除已存在的任务(防止重复执行) // 移除已存在的任务(防止重复执行)
await RemoveExistingJobAsync(scheduler, jobKey); await RemoveExistingJobAsync(scheduler, jobKey);
// 创建任务详情(添加禁止并发执行特性,避免顺序混乱) // 创建任务详情(保留禁止并发执行,避免同一任务重复运行)
var jobDetail = JobBuilder.Create(jobConfig.JobType) var jobDetail = JobBuilder.Create(jobConfig.JobType)
.WithIdentity(jobKey) .WithIdentity(jobKey)
.WithDescription(jobConfig.Description) .WithDescription(jobConfig.Description)
.DisallowConcurrentExecution() // 关键:禁止同一任务并发执行 .DisallowConcurrentExecution() // 禁止同一任务并发执行
.Build(); .Build();
// 创建立触发器 // 创建定时触发器(仅使用定时触发,移除依赖触发逻辑)
ITrigger trigger = isDependencyTrigger ITrigger trigger = CreateScheduledTrigger(triggerKey, expression, jobConfig.Description);
? CreateDependencyTrigger(triggerKey, jobConfig.Description) // 依赖触发:立即执行
: CreateScheduledTrigger(triggerKey, expression, jobConfig.Description); // 定时触发:按表达式执行
// 调度任务 // 调度任务
await scheduler.ScheduleJob(jobDetail, trigger); await scheduler.ScheduleJob(jobDetail, trigger);
Log.Information("【任务服务】启动任务成功 - 任务描述: {JobDescription}, 触发类型: {TriggerType}, 表达式: {Expression}", Log.Information("【任务服务】启动任务成功 - 任务描述: {JobDescription}, 执行频率: {Expression}",
jobConfig.Description, jobConfig.Description,
isDependencyTrigger ? "依赖触发(立即执行)" : "定时触发", expression);
isDependencyTrigger ? "无" : expression);
return true; return true;
} }
@@ -225,7 +220,7 @@ namespace dy.net.service
} }
/// <summary> /// <summary>
/// 启动单次执行任务(保持原有逻辑不变) /// 启动单次执行任务
/// </summary> /// </summary>
private async Task<bool> StartOneTimeJobAsync(string configKey) private async Task<bool> StartOneTimeJobAsync(string configKey)
{ {
@@ -268,9 +263,9 @@ namespace dy.net.service
} }
/// <summary> /// <summary>
/// 创建「定时触发器」(按表达式执行,仅第一个任务使用) /// 创建定时触发器(支持Cron表达式或分钟间隔)
/// </summary> /// </summary>
private ITrigger CreateScheduledTrigger(TriggerKey triggerKey, string expression, string jobDescription) private static ITrigger CreateScheduledTrigger(TriggerKey triggerKey, string expression, string jobDescription)
{ {
// Cron表达式格式 // Cron表达式格式
if (CronExpression.IsValidExpression(expression)) if (CronExpression.IsValidExpression(expression))
@@ -312,21 +307,9 @@ namespace dy.net.service
} }
/// <summary> /// <summary>
/// 创建「依赖触发器」(立即执行,仅执行一次) /// 移除已存在的任务
/// </summary> /// </summary>
private ITrigger CreateDependencyTrigger(TriggerKey triggerKey, string jobDescription) private static async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey)
{
return TriggerBuilder.Create()
.WithIdentity(triggerKey)
.WithDescription($"{jobDescription} - 依赖触发(立即执行)")
.StartNow() // 立即触发
.Build();
}
/// <summary>
/// 移除已存在的任务(保持原有逻辑不变)
/// </summary>
private async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey)
{ {
if (await scheduler.CheckExists(jobKey)) if (await scheduler.CheckExists(jobKey))
{ {
@@ -334,6 +317,5 @@ namespace dy.net.service
await scheduler.DeleteJob(jobKey); 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.repository;
using dy.net.utils; using dy.net.utils;
using Newtonsoft.Json; using Newtonsoft.Json;
using Serilog;
using SqlSugar; using SqlSugar;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace dy.net.service namespace dy.net.service
@@ -81,6 +83,7 @@ namespace dy.net.service
{ {
existingVideo.VideoSavePath = updateData.VideoSavePath; existingVideo.VideoSavePath = updateData.VideoSavePath;
existingVideo.VideoCoverSavePath = updateData.VideoCoverSavePath; existingVideo.VideoCoverSavePath = updateData.VideoCoverSavePath;
existingVideo.ViedoType = updateData.ViedoType;
} }
} }
// 批量更新数据库 // 批量更新数据库
@@ -94,6 +97,10 @@ namespace dy.net.service
return transaction; return transaction;
} }
public async Task<bool> UpdateOne(DouyinVideo video)
{
return await _dyCollectVideoRepository.UpdateAsync(video);
}
public async Task<VideoStaticsDto> GetStatics() public async Task<VideoStaticsDto> GetStatics()
{ {
@@ -110,14 +117,17 @@ namespace dy.net.service
VideoCount = list.Count, VideoCount = list.Count,
Categories = Categories, Categories = Categories,
FavoriteCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_favorite), 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), FollowCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_follows),
GraphicVideoCount = list.Count(x => x.IsMergeVideo == 1), 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)), VideoSizeTotal = DouyinFileUtils.ConvertBytesToGb(list.Sum(x => x.FileSize)),
VideoFavoriteSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_favorite).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)), 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)), GraphicVideoSize = DouyinFileUtils.ConvertBytesToGb(list.Where(x => x.IsMergeVideo == 1).Sum(x => x.FileSize)),
//TotalDiskSize= ByteToGbConverter.GetHostTotalDiskSpaceGB(), //TotalDiskSize= ByteToGbConverter.GetHostTotalDiskSpaceGB(),
@@ -129,6 +139,28 @@ namespace dy.net.service
data.GraphicVideoSize = "<0.01";//避免显示0.00误导用户 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 data.Authors = list.GroupBy(x => x.Author).Select(x => new VideoStaticsItemDto
{ {
Name = x.Key, Name = x.Key,
@@ -170,10 +202,10 @@ namespace dy.net.service
/// <param name="AuthorId"></param> /// <param name="AuthorId"></param>
/// <param name="ViedoNameSimplify"></param> /// <param name="ViedoNameSimplify"></param>
/// <returns></returns> /// <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> /// <summary>
@@ -346,7 +378,27 @@ namespace dy.net.service
{ {
return await _dyCollectVideoRepository.GetTopsOrderBySyncTime(top); 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> /// <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;
}
} }
} }
+57 -34
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using dy.net.model.dto;
using System.Collections.Generic;
namespace dy.net.utils namespace dy.net.utils
{ {
@@ -8,6 +9,46 @@ namespace dy.net.utils
/// </summary> /// </summary>
public static class DouyinRequestParamManager 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 全局常量(接口调用公共配置) #region 全局常量(接口调用公共配置)
/// <summary> /// <summary>
/// 抖音全局域名 /// 抖音全局域名
@@ -399,42 +440,24 @@ namespace dy.net.utils
} }
#endregion #endregion
#region 基础参数(直接返回模板引用,存在交叉修改风险)
/// <summary> /// <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> /// </summary>
/// <param name="videoType"></param>
/// <returns></returns> /// <returns></returns>
private static Dictionary<string, string> InitBaseParams() public static string GetDesc(this VideoTypeEnum videoType)
{ {
return new Dictionary<string, string>(_baseParamTemplate); return videoType switch
} {
#endregion VideoTypeEnum.dy_favorite => "喜欢",
VideoTypeEnum.dy_collects => "默认收藏夹",
VideoTypeEnum.dy_follows => "关注",
VideoTypeEnum.ImageVideo => "图文视频",
VideoTypeEnum.dy_custom_collect => "自定义收藏夹",
VideoTypeEnum.dy_mix => "合集",
VideoTypeEnum.dy_series => "短剧",
_ => string.Empty // 匹配所有未定义的枚举值,返回空字符串(替代原 default)
};
}
} }
} }
+20 -8
View File
@@ -45,17 +45,17 @@ namespace dy.net.utils
} }
var nfoActorFullPath = Path.Combine(actorsDir, $"{video.Author}{fileExt}"); var nfoActorFullPath = Path.Combine(actorsDir, $"{video.Author}{fileExt}");
if (File.Exists(nfoActorFullPath)) if (!File.Exists(nfoActorFullPath))
{ {
File.Delete(nfoActorFullPath);
}
// 执行复制(CopyTo支持覆盖,但先删除更可控)
File.Copy(video.AuthorAvatar, nfoActorFullPath, overwrite: true); File.Copy(video.AuthorAvatar, nfoActorFullPath, overwrite: true);
//File.Delete(video.AuthorAvatar);
}
} }
} }
} }
GenerateNfoFile(new DouyinVideoNfo
var nfoInfo= new DouyinVideoNfo
{ {
Actors = new List<Actor> Actors = new List<Actor>
{ {
@@ -70,7 +70,19 @@ namespace dy.net.utils
Thumbnail = "poster.jpg",// 使用poster作为缩略图 Thumbnail = "poster.jpg",// 使用poster作为缩略图
ReleaseDate = video.CreateTime, ReleaseDate = video.CreateTime,
Genres = new List<string> { video.Tag1, video.Tag2, video.Tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList() 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) 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 try
{ {
@@ -89,7 +101,7 @@ namespace dy.net.utils
throw new ArgumentException("文件路径不能为空", nameof(filePath)); throw new ArgumentException("文件路径不能为空", nameof(filePath));
// 创建根元素 // 创建根元素
XElement root = new XElement("movie"); XElement root = new XElement(xmlRoot);
//root.Add(new XElement("outline")); //root.Add(new XElement("outline"));
root.Add(new XElement("lockdata", true)); root.Add(new XElement("lockdata", true));
//root.Add(new XElement("director", videoInfo.Author)); //root.Add(new XElement("director", videoInfo.Author));