diff --git a/Controllers/VideoController.cs b/Controllers/VideoController.cs index 7204362..a2d67ac 100644 --- a/Controllers/VideoController.cs +++ b/Controllers/VideoController.cs @@ -4,6 +4,7 @@ using dy.net.service; using dy.net.utils; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; using Newtonsoft.Json; using System.Drawing.Printing; using System.Threading.Tasks; @@ -15,12 +16,12 @@ namespace dy.net.Controllers public class VideoController : ControllerBase { private readonly DouyinVideoService dyCollectVideoService; - private readonly DouyinQuartzJobService douyinQuartzJobService; + private readonly DouyinCommonService douyinCommonService; - public VideoController(DouyinVideoService dyCollectVideoService,DouyinQuartzJobService douyinQuartzJobService) + public VideoController(DouyinVideoService dyCollectVideoService, DouyinCommonService douyinCommonService) { this.dyCollectVideoService = dyCollectVideoService; - this.douyinQuartzJobService = douyinQuartzJobService; + this.douyinCommonService = douyinCommonService; } /// /// 分页查询收藏视频 @@ -175,6 +176,7 @@ namespace dy.net.Controllers }; } + /// /// 重新下载 /// @@ -193,7 +195,6 @@ namespace dy.net.Controllers var result = await dyCollectVideoService.ReDownloadViedoAsync(dto); if (result) { - //douyinQuartzJobService.StartReDownJobOnceAsync();...无法实现。。逆向失败 return Ok(new { code = 0, data = true }); } else @@ -202,5 +203,42 @@ namespace dy.net.Controllers } } } + /// + /// 删除视频-不再下载 + /// + /// + /// + [HttpGet("vdelete/{vid}")] + public async Task DeleteVideo([FromRoute]string vid) + { + if (string.IsNullOrWhiteSpace(vid)) + { + return Ok(new { code = -1, data = false }); + } + else + { + var video = await dyCollectVideoService.GetById(vid); + if (video == null) + { + return Ok(new { code = -1, data = false }); + } + else + { + var result = await dyCollectVideoService.ReDownloadViedoAsync(new ReDownViedoDto { Ids = new List { vid } }); + if (result) + { + //加入删除逻辑 + await douyinCommonService.AddDeleteVideo(new DouyinVideoDelete { ViedoId = video.AwemeId }); + return Ok(new { code = 0, data = true }); + } + else + { + return Ok(new { code = -1, data = false }); + } + + } + + } + } } } diff --git a/Program.cs b/Program.cs index 967cf78..d036a9d 100644 --- a/Program.cs +++ b/Program.cs @@ -1,15 +1,7 @@ -using ClockSnowFlake; -using dy.net.dto; using dy.net.extension; -using dy.net.model; using dy.net.service; using dy.net.utils; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Extensions.FileProviders; -using Microsoft.IdentityModel.Tokens; using Serilog; -using SqlSugar; -using System.Reflection; using System.Text; namespace dy.net @@ -204,8 +196,8 @@ ____/ _|_)_____/ _| _| \_|\____| commonService.UpdateCollectViedoType(); // òƷͬ״̬Ϊδͬ commonService.UpdateAllCookieSyncedToZero(); - - if(!isDevelopment) + + if (!isDevelopment) { // ʱ var quartzJobService = services.GetRequiredService(); diff --git a/Properties/PublishProfiles/FolderProfile.pubxml.user b/Properties/PublishProfiles/FolderProfile.pubxml.user index 0dba74a..387043c 100644 --- a/Properties/PublishProfiles/FolderProfile.pubxml.user +++ b/Properties/PublishProfiles/FolderProfile.pubxml.user @@ -5,7 +5,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121. <_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\ - True|2025-12-02T14:11:37.4645042Z||;False|2025-12-02T22:10:47.1320259+08:00||;True|2025-12-02T14:39:58.8932130+08:00||;True|2025-12-02T14:36:37.1529072+08:00||;True|2025-12-02T12:58:22.0951548+08:00||;True|2025-12-02T09:30:33.7066474+08:00||;True|2025-12-02T09:30:14.5481844+08:00||;True|2025-12-02T09:30:00.3123364+08:00||;False|2025-12-02T09:29:54.4615520+08:00||;True|2025-12-02T09:13:01.7995414+08:00||;False|2025-12-02T09:12:55.8360281+08:00||;True|2025-12-02T09:12:31.0156791+08:00||;True|2025-12-02T08:55:11.3773395+08:00||;True|2025-12-02T08:53:21.3927713+08:00||;False|2025-12-02T08:48:55.8638037+08:00||;True|2025-12-02T07:29:25.2192447+08:00||;False|2025-12-02T07:29:10.2504665+08:00||;True|2025-12-02T07:28:29.0769286+08:00||;True|2025-12-01T21:53:07.6474834+08:00||;True|2025-12-01T21:44:28.1674315+08:00||;True|2025-12-01T21:18:47.2119609+08:00||;True|2025-12-01T20:51:19.9948301+08:00||;True|2025-12-01T20:50:36.9904697+08:00||;True|2025-12-01T20:44:10.1695142+08:00||;True|2025-12-01T19:27:58.0479456+08:00||;True|2025-12-01T19:16:02.2288214+08:00||;False|2025-12-01T19:15:56.0372115+08:00||;True|2025-12-01T19:15:16.4854821+08:00||;False|2025-12-01T19:15:06.7001019+08:00||;True|2025-12-01T17:10:38.9739466+08:00||;False|2025-12-01T17:10:18.8928139+08:00||;True|2025-12-01T17:03:59.3171589+08:00||;True|2025-12-01T17:03:12.5681656+08:00||;True|2025-12-01T16:42:51.4415025+08:00||;True|2025-12-01T01:29:59.6975567+08:00||;True|2025-12-01T01:29:45.1898652+08:00||;False|2025-12-01T01:29:33.1787721+08:00||;True|2025-12-01T01:27:33.1297605+08:00||;True|2025-12-01T01:27:27.3976862+08:00||;False|2025-12-01T01:27:20.5193052+08:00||;True|2025-12-01T01:11:11.0210582+08:00||;True|2025-12-01T01:04:04.3307782+08:00||;True|2025-12-01T00:55:49.8018864+08:00||;True|2025-12-01T00:27:25.1323561+08:00||;True|2025-11-30T23:58:20.7284116+08:00||;True|2025-11-30T23:57:58.3027622+08:00||;True|2025-11-30T23:57:43.0829614+08:00||;False|2025-11-30T23:57:33.5606716+08:00||;True|2025-11-30T23:54:29.6203368+08:00||;True|2025-11-30T23:54:27.5383120+08:00||;False|2025-11-30T23:54:17.0823982+08:00||;True|2025-11-30T22:41:11.4538820+08:00||;True|2025-11-30T22:38:19.7064566+08:00||;True|2025-11-30T22:37:35.2889186+08:00||;True|2025-11-30T21:42:31.1408314+08:00||;True|2025-11-30T21:42:19.5385333+08:00||;False|2025-11-30T21:42:13.3290237+08:00||;True|2025-11-30T21:33:28.0806806+08:00||;True|2025-11-30T21:16:56.7088027+08:00||;False|2025-11-30T21:16:46.1444944+08:00||;True|2025-11-30T20:21:24.4059664+08:00||;False|2025-11-30T20:21:17.6006098+08:00||;True|2025-11-30T20:20:08.2030141+08:00||;False|2025-11-30T20:16:12.3608968+08:00||;True|2025-11-30T20:15:35.6048313+08:00||;True|2025-11-30T19:45:18.7229552+08:00||;True|2025-11-30T19:45:08.6083368+08:00||;False|2025-11-30T19:44:54.7824905+08:00||;True|2025-11-30T19:44:17.0179491+08:00||;True|2025-11-30T19:44:06.7073484+08:00||;False|2025-11-30T19:43:58.2896556+08:00||;True|2025-11-30T19:42:08.2261927+08:00||;True|2025-11-30T19:28:15.7115077+08:00||;False|2025-11-30T19:28:10.2280725+08:00||;True|2025-11-30T19:23:31.7859158+08:00||;True|2025-11-30T19:23:20.7788277+08:00||;True|2025-11-30T19:17:11.8583296+08:00||;False|2025-11-30T19:10:41.8369574+08:00||;True|2025-11-30T19:02:08.8184758+08:00||;False|2025-11-30T19:01:59.4711045+08:00||;True|2025-11-30T18:16:01.8372242+08:00||;True|2025-11-30T14:50:31.9356543+08:00||;True|2025-11-30T14:48:27.1833064+08:00||;True|2025-11-30T02:06:06.6099669+08:00||;True|2025-11-30T01:49:46.4358206+08:00||;False|2025-11-30T01:49:34.4884036+08:00||;True|2025-11-30T01:43:41.1028753+08:00||;True|2025-11-30T01:38:18.2856609+08:00||;True|2025-11-30T01:23:02.4378259+08:00||;True|2025-11-30T01:08:29.4041159+08:00||;True|2025-11-30T00:56:33.4172007+08:00||;False|2025-11-30T00:56:21.2777389+08:00||;True|2025-11-27T13:37:08.6884390+08:00||;False|2025-11-27T13:36:54.2595622+08:00||;True|2025-11-27T11:03:33.8401644+08:00||;False|2025-11-27T11:02:48.8942827+08:00||;True|2025-11-26T23:48:02.3957186+08:00||;True|2025-11-26T23:43:06.8154188+08:00||;False|2025-11-26T23:42:05.9191485+08:00||;True|2025-11-26T23:30:11.1295861+08:00||; + True|2025-12-03T15:34:17.1648971Z||;False|2025-12-03T23:34:07.7649161+08:00||;True|2025-12-03T22:32:56.2435003+08:00||;True|2025-12-03T22:27:45.6059666+08:00||;True|2025-12-03T15:55:28.0186597+08:00||;False|2025-12-03T15:54:17.5032724+08:00||;True|2025-12-03T15:53:10.2273509+08:00||;True|2025-12-02T22:11:37.4645042+08:00||;False|2025-12-02T22:10:47.1320259+08:00||;True|2025-12-02T14:39:58.8932130+08:00||;True|2025-12-02T14:36:37.1529072+08:00||;True|2025-12-02T12:58:22.0951548+08:00||;True|2025-12-02T09:30:33.7066474+08:00||;True|2025-12-02T09:30:14.5481844+08:00||;True|2025-12-02T09:30:00.3123364+08:00||;False|2025-12-02T09:29:54.4615520+08:00||;True|2025-12-02T09:13:01.7995414+08:00||;False|2025-12-02T09:12:55.8360281+08:00||;True|2025-12-02T09:12:31.0156791+08:00||;True|2025-12-02T08:55:11.3773395+08:00||;True|2025-12-02T08:53:21.3927713+08:00||;False|2025-12-02T08:48:55.8638037+08:00||;True|2025-12-02T07:29:25.2192447+08:00||;False|2025-12-02T07:29:10.2504665+08:00||;True|2025-12-02T07:28:29.0769286+08:00||;True|2025-12-01T21:53:07.6474834+08:00||;True|2025-12-01T21:44:28.1674315+08:00||;True|2025-12-01T21:18:47.2119609+08:00||;True|2025-12-01T20:51:19.9948301+08:00||;True|2025-12-01T20:50:36.9904697+08:00||;True|2025-12-01T20:44:10.1695142+08:00||;True|2025-12-01T19:27:58.0479456+08:00||;True|2025-12-01T19:16:02.2288214+08:00||;False|2025-12-01T19:15:56.0372115+08:00||;True|2025-12-01T19:15:16.4854821+08:00||;False|2025-12-01T19:15:06.7001019+08:00||;True|2025-12-01T17:10:38.9739466+08:00||;False|2025-12-01T17:10:18.8928139+08:00||;True|2025-12-01T17:03:59.3171589+08:00||;True|2025-12-01T17:03:12.5681656+08:00||;True|2025-12-01T16:42:51.4415025+08:00||;True|2025-12-01T01:29:59.6975567+08:00||;True|2025-12-01T01:29:45.1898652+08:00||;False|2025-12-01T01:29:33.1787721+08:00||;True|2025-12-01T01:27:33.1297605+08:00||;True|2025-12-01T01:27:27.3976862+08:00||;False|2025-12-01T01:27:20.5193052+08:00||;True|2025-12-01T01:11:11.0210582+08:00||;True|2025-12-01T01:04:04.3307782+08:00||;True|2025-12-01T00:55:49.8018864+08:00||;True|2025-12-01T00:27:25.1323561+08:00||;True|2025-11-30T23:58:20.7284116+08:00||;True|2025-11-30T23:57:58.3027622+08:00||;True|2025-11-30T23:57:43.0829614+08:00||;False|2025-11-30T23:57:33.5606716+08:00||;True|2025-11-30T23:54:29.6203368+08:00||;True|2025-11-30T23:54:27.5383120+08:00||;False|2025-11-30T23:54:17.0823982+08:00||;True|2025-11-30T22:41:11.4538820+08:00||;True|2025-11-30T22:38:19.7064566+08:00||;True|2025-11-30T22:37:35.2889186+08:00||;True|2025-11-30T21:42:31.1408314+08:00||;True|2025-11-30T21:42:19.5385333+08:00||;False|2025-11-30T21:42:13.3290237+08:00||;True|2025-11-30T21:33:28.0806806+08:00||;True|2025-11-30T21:16:56.7088027+08:00||;False|2025-11-30T21:16:46.1444944+08:00||;True|2025-11-30T20:21:24.4059664+08:00||;False|2025-11-30T20:21:17.6006098+08:00||;True|2025-11-30T20:20:08.2030141+08:00||;False|2025-11-30T20:16:12.3608968+08:00||;True|2025-11-30T20:15:35.6048313+08:00||;True|2025-11-30T19:45:18.7229552+08:00||;True|2025-11-30T19:45:08.6083368+08:00||;False|2025-11-30T19:44:54.7824905+08:00||;True|2025-11-30T19:44:17.0179491+08:00||;True|2025-11-30T19:44:06.7073484+08:00||;False|2025-11-30T19:43:58.2896556+08:00||;True|2025-11-30T19:42:08.2261927+08:00||;True|2025-11-30T19:28:15.7115077+08:00||;False|2025-11-30T19:28:10.2280725+08:00||;True|2025-11-30T19:23:31.7859158+08:00||;True|2025-11-30T19:23:20.7788277+08:00||;True|2025-11-30T19:17:11.8583296+08:00||;False|2025-11-30T19:10:41.8369574+08:00||;True|2025-11-30T19:02:08.8184758+08:00||;False|2025-11-30T19:01:59.4711045+08:00||;True|2025-11-30T18:16:01.8372242+08:00||;True|2025-11-30T14:50:31.9356543+08:00||;True|2025-11-30T14:48:27.1833064+08:00||;True|2025-11-30T02:06:06.6099669+08:00||;True|2025-11-30T01:49:46.4358206+08:00||;False|2025-11-30T01:49:34.4884036+08:00||;True|2025-11-30T01:43:41.1028753+08:00||;True|2025-11-30T01:38:18.2856609+08:00||;True|2025-11-30T01:23:02.4378259+08:00||;True|2025-11-30T01:08:29.4041159+08:00||;True|2025-11-30T00:56:33.4172007+08:00||;False|2025-11-30T00:56:21.2777389+08:00||;True|2025-11-27T13:37:08.6884390+08:00||; diff --git a/README.md b/README.md index f3b2b65..319aa00 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取, | 镜像标签 | 架构 | | ----------------- | -------------- | -| `beta_1.7.1` | x86_64 (amd64) | -| `arm_1.7.1` | ARM64 | +| `beta_1.7.3` | x86_64 (amd64) | +| `arm_1.7.3` | ARM64 | ### 最新镜像查看 [镜像列表](http://nas.synology2023.online:10108/api/docker/dysync/1) @@ -112,7 +112,7 @@ docker run -d --restart=always \ -v /opt/dysync/uper:/app/uper \ -p 10103:10101 \ --name dysync2025 \ - ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_1.7.1 + ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_1.7.3 # 注意:-p 后面的容器端口,可以用环境变量类似:ASPNETCORE_URLS = http://+:10108 指定 ``` @@ -126,7 +126,7 @@ version: '3.8' services: dysync: - image: ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_1.7.1 + image: ccr.ccs.tencentyun.com/jianzhichu/dysync:beta_1.7.3 container_name: dysync2025 # 容器名称 restart: unless-stopped # 始终重启容器,除非容器被手动停止或Docker服务停止 ports: @@ -167,6 +167,8 @@ services:  + +   @@ -189,7 +191,7 @@ services: 8. ✅ Cookie 过期提醒,在D音授权页可查看 -9. ✅ 重复视频去重(一个视频同时属于收藏视频、喜欢的视频或指定的博主作品) +9. ✅ 自动根据去重规则进行去重(可设置去重优先级,同一个视频出现再多个分类时适用) 10. ✅ 在同步记录页面中直接播放视频 @@ -199,4 +201,6 @@ services: 12. ✅ 关注列表支持新增,非关注的博主 +13. ✅ 增加永久删除功能,删除后,以后不会再同步该视频 + diff --git a/app/src/pages/set/AppSet.vue b/app/src/pages/set/AppSet.vue index 96d0102..e44a5bc 100644 --- a/app/src/pages/set/AppSet.vue +++ b/app/src/pages/set/AppSet.vue @@ -13,11 +13,11 @@ - - + + - 每次同步获取的条数,范围10-30条,建议使用默认值 + 每次同步获取的条数,范围10-30条(建议使用默认值18,抖音默认的分页大小) @@ -121,24 +121,7 @@ 其他配置 - - - - - 系统运行日志的保留天数,范围1-90天,过期自动清理 - - - - - - - - 启用后,同一个视频,只会下载一次。(但是暂时不能决定保留哪个文件夹的) - - - - - + @@ -150,8 +133,30 @@ + + + 同一个视频同时存在在多个类型的视频分类时,按照优先级保优先级最高的一个。 + 鼠标放到≡,点击鼠标左键即可拖拽调整优先级, + + + + + + 系统运行日志的保留天数,范围1-90天,过期自动清理 + + + + @@ -197,7 +202,7 @@ const template_options = [ // 状态定义 const componentDisabled = ref(true); -const downImgVideo = ref(false); +const downImgVideo = ref(true); // 表单数据结构(新增 FullFollowedTitleTemplate 字段) interface FormState { @@ -315,7 +320,7 @@ const getConfig = () => { PriorityLevel: res.data.priorityLevel, }); - downImgVideo.value = res.data.downImageVideoFromEnv; + // downImgVideo.value = res.data.downImageVideoFromEnv; tagData.value = JSON.parse(res.data.priorityLevel); } else { message.error(res.erro || '获取配置失败', 8); diff --git a/app/src/pages/workplace/RecordTable.vue b/app/src/pages/workplace/RecordTable.vue index 0bb5d60..3391bbd 100644 --- a/app/src/pages/workplace/RecordTable.vue +++ b/app/src/pages/workplace/RecordTable.vue @@ -125,6 +125,10 @@ 分享 + + + 删除 + @@ -225,7 +229,7 @@ const columns = ref([ title: '同步类型', dataIndex: 'viedoTypeStr', align: 'center', - width: 150, + width: 120, }, { title: '博主', @@ -236,7 +240,7 @@ const columns = ref([ { title: '视频类型', dataIndex: 'viedoCate', - width: 300, + width: 200, align: 'center', }, { @@ -276,7 +280,7 @@ watch(isBatchMode, (isOpen) => { // 基础状态(优化:删除冗余的 datas 响应式数组) const loading = ref(false); -const showImageViedo = ref(false); +const showImageViedo = ref(true); const dataSource = ref([]); // 直接用 ref 数组存储表格数据,减少响应式嵌套 // 查询参数 @@ -306,7 +310,16 @@ const pagination = ref({ current: 1, defaultPageSize: 10, total: 0, + showSizeChanger: true, // 强制显示「每页显示数量」下拉框(关键修复) showTotal: () => `共 ${0} 条`, + // showQuickJumper: true, // 显示快速跳转输入框(可选,增强体验) + pageSizeOptions: ['10', '20', '50', '100'], // 自定义每页条数选项(可选) + showSizeChange: (current, pageSize) => { + // 可选:监听每页条数变化,重置当前页为第1页(避免最后一页数据不足的问题) + pagination.value.current = 1; + pagination.value.defaultPageSize = pageSize; + GetRecords(); + }, }); // 视频播放相关配置 @@ -450,22 +463,22 @@ const handleTableChange = (paginationObj: any) => { }; /** 获取系统配置 */ -const getConfig = () => { - useApiStore() - .apiGetConfig() - .then((res) => { - if (res.code === 0) { - showImageViedo.value = res.data.downImageVideoFromEnv; - } else { - message.warning(`获取配置失败: ${res.message}`); - } - GetRecords(); - }) - .catch((error) => { - console.error('获取配置失败:', error); - message.error('获取配置失败,请稍后重试'); - }); -}; +// const getConfig = () => { +// useApiStore() +// .apiGetConfig() +// .then((res) => { +// if (res.code === 0) { +// showImageViedo.value = res.data.downImageVideoFromEnv; +// } else { +// message.warning(`获取配置失败: ${res.message}`); +// } +// GetRecords(); +// }) +// .catch((error) => { +// console.error('获取配置失败:', error); +// message.error('获取配置失败,请稍后重试'); +// }); +// }; /** 视频类型切换事件 */ const onViedoTypeChanged = () => { @@ -613,9 +626,9 @@ const handleBatchDelete = () => { } Modal.confirm({ - title: '确认删除', - content: `您确定要删除选中的 ${selectedRowKeys.value.length} 条视频数据吗?此操作不可撤销!`, - okText: '确认删除', + title: '确认重新下载吗', + content: `您确定要重新下载选中的 ${selectedRowKeys.value.length} 条视频数据吗?`, + okText: '确认重新下载', cancelText: '取消', okType: 'danger', onOk: async () => { @@ -753,6 +766,34 @@ const handleShare = (record: DataItem) => { } }; +//视频删除不再下载 +const handleDelete = (record: DataItem) => { + Modal.confirm({ + title: '确认删除', + content: `您确定要删除这条视频数据吗?此操作不可撤销,以后也不会再下载!!!`, + okText: '确认删除', + cancelText: '取消', + okType: 'danger', + onOk: async () => { + try { + useApiStore() + .DeleteVideo(record.id) + .then((res) => { + if (res.code == 0) { + message.success('删除成功,再也不会下载!!!'); + } else { + message.error('删除失败'); + } + GetRecords(); + }); + } catch (error) { + console.error('删除失败', error); + message.error('视频删除失败,请稍后再试'); + } + }, + }); +}; + // 新增:复制视频路径方法 const copyVideoPath = (path?: string) => { if (!path) { @@ -764,7 +805,8 @@ const copyVideoPath = (path?: string) => { // -------------------------- 页面初始化 -------------------------- onMounted(() => { - getConfig(); + // getConfig(); + GetRecords(); }); diff --git a/app/src/store/coreapi.ts b/app/src/store/coreapi.ts index 138d154..1bf1da3 100644 --- a/app/src/store/coreapi.ts +++ b/app/src/store/coreapi.ts @@ -210,6 +210,14 @@ export const useApiStore = defineStore('coreapi', () => { }); } + //删除 + async function DeleteVideo(param: string) { + return http.request>('/api/video/vdelete/' + param, 'get').then(r => { + return r.data; + }).finally(() => { + + }); + } //检查版本 @@ -260,6 +268,7 @@ export const useApiStore = defineStore('coreapi', () => { SyncFollow, OpenOrCloseSync, OpenOrCloseFullSync, - ReDownViedos + ReDownViedos, + DeleteVideo }; }); diff --git a/appsettings.json b/appsettings.json index 22e9313..5a78556 100644 --- a/appsettings.json +++ b/appsettings.json @@ -1,5 +1,5 @@ { "dbconn": "", "dbtype": "Sqlite", - "tagName": "dev_1.0" + "tagName": "beta_1.7.3" } \ No newline at end of file diff --git a/docs/appconf.png b/docs/appconf.png new file mode 100644 index 0000000..af26473 Binary files /dev/null and b/docs/appconf.png differ diff --git a/dto/JobConfig.cs b/dto/JobConfig.cs index f4ccd6e..ff53e82 100644 --- a/dto/JobConfig.cs +++ b/dto/JobConfig.cs @@ -3,34 +3,51 @@ /// /// 任务配置实体 /// + //public class JobConfig + //{ + // public JobConfig(Type jobType, string jobKey, string triggerKey, string description) + // { + // JobType = jobType ?? throw new ArgumentNullException(nameof(jobType)); + // JobKey = jobKey ?? throw new ArgumentNullException(nameof(jobKey)); + // TriggerKey = triggerKey ?? throw new ArgumentNullException(nameof(triggerKey)); + // Description = description ?? throw new ArgumentNullException(nameof(description)); + // } + + // /// + // /// 任务类型 + // /// + // public Type JobType { get; } + + // /// + // /// 任务Key + // /// + // public string JobKey { get; } + + // /// + // /// 触发器Key + // /// + // public string TriggerKey { get; } + + // /// + // /// 任务描述 + // /// + // public string Description { get; } + //} + + public class JobConfig { + public Type JobType { get; } + public string JobKey { get; } + public string TriggerKey { get; } + public string Description { get; } + public JobConfig(Type jobType, string jobKey, string triggerKey, string description) { - JobType = jobType ?? throw new ArgumentNullException(nameof(jobType)); - JobKey = jobKey ?? throw new ArgumentNullException(nameof(jobKey)); - TriggerKey = triggerKey ?? throw new ArgumentNullException(nameof(triggerKey)); - Description = description ?? throw new ArgumentNullException(nameof(description)); + JobType = jobType ?? throw new ArgumentNullException(nameof(jobType), "任务类型不能为空"); + JobKey = jobKey ?? throw new ArgumentNullException(nameof(jobKey), "任务Key不能为空"); + TriggerKey = triggerKey ?? throw new ArgumentNullException(nameof(triggerKey), "触发器Key不能为空"); + Description = description ?? throw new ArgumentNullException(nameof(description), "任务描述不能为空"); } - - /// - /// 任务类型 - /// - public Type JobType { get; } - - /// - /// 任务Key - /// - public string JobKey { get; } - - /// - /// 触发器Key - /// - public string TriggerKey { get; } - - /// - /// 任务描述 - /// - public string Description { get; } } } diff --git a/dto/VideoTypeEnum.cs b/dto/VideoTypeEnum.cs index 52a0878..393c255 100644 --- a/dto/VideoTypeEnum.cs +++ b/dto/VideoTypeEnum.cs @@ -4,24 +4,26 @@ namespace dy.net.dto { public enum VideoTypeEnum { + /// + ///喜欢的 + /// [Description("喜欢的")] - Favorite = 1, + dy_favorite = 1, + /// + ///收藏的 + /// [Description("收藏的")] - Collect = 2, + dy_collects = 2, + /// + /// 关注的 + /// [Description("关注的")] - UperPost = 3, + dy_follows = 3, + /// + /// 图文视频,无用了,但是不能删... + /// [Description("图片视频")] ImageVideo = 4 } - - public enum QuartzJobTypeEnum - { - [Description("[Favorite]")] - Favorite = 1, - [Description("[Collect]")] - Collect = 2, - [Description("[Followed]")] - Followed = 3 - } } diff --git a/job/DependencyJobListener.cs b/job/DependencyJobListener.cs new file mode 100644 index 0000000..2d063b5 --- /dev/null +++ b/job/DependencyJobListener.cs @@ -0,0 +1,109 @@ +using dy.net.dto; +using dy.net.service; +using Quartz; +using Serilog; +using static Quartz.Logging.OperationName; + +namespace dy.net.job +{ /// + /// 抖音任务依赖监听器(独立公共类) + /// 作用:监听任务执行完成事件,触发下一个依赖任务,实现顺序执行 + /// + public class DouyinJobDependencyListener : IJobListener + { + // 监听器名称(唯一标识,不可重复) + public string Name => "DouyinJobDependencyListener"; + + /// + /// 任务配置字典(从外部注入) + /// + private readonly Dictionary _jobConfigs; + + /// + /// 任务依赖关系(从外部注入,定义执行顺序) + /// + private readonly Dictionary _jobDependency; + + /// + /// 任务服务(用于触发下一个任务,从外部注入) + /// + private readonly DouyinQuartzJobService _jobService; + + /// + /// 构造函数(依赖注入) + /// + /// 任务配置 + /// 任务依赖关系 + /// 任务服务 + public DouyinJobDependencyListener( + Dictionary jobConfigs, + Dictionary jobDependency, + DouyinQuartzJobService jobService) + { + _jobConfigs = jobConfigs ?? throw new ArgumentNullException(nameof(jobConfigs), "任务配置不能为空"); + _jobDependency = jobDependency ?? throw new ArgumentNullException(nameof(jobDependency), "任务依赖关系不能为空"); + _jobService = jobService ?? throw new ArgumentNullException(nameof(jobService), "任务服务不能为空"); + } + + /// + /// 任务执行前触发(无需处理) + /// + public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + /// + /// 任务被否决执行时触发(无需处理) + /// + public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + /// + /// 任务执行完成后触发(核心逻辑:触发下一个依赖任务) + /// + public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default) + { + var currentJobKey = context.JobDetail.Key; + Log.Information("【任务监听】任务执行完成 - 任务名称: {JobName}, 执行状态: {Status}", + currentJobKey.Name, jobException == null ? "成功" : "失败"); + + // 1. 若当前任务执行失败,终止后续依赖任务(避免无效执行) + if (jobException != null) + { + Log.Error(jobException, "【任务监听】任务 {JobName} 执行失败,终止后续任务链条", currentJobKey.Name); + return; + } + + // 2. 根据当前任务的 JobKey,找到对应的配置 Key(如:dy.job.key.collect → collect) + var currentConfigKey = _jobConfigs.FirstOrDefault(kv => kv.Value.JobKey == currentJobKey.Name).Key; + if (string.IsNullOrEmpty(currentConfigKey)) + { + Log.Warning("【任务监听】未找到任务 {JobName} 的配置信息,任务链条终止", currentJobKey.Name); + return; + } + + // 3. 查找下一个依赖任务的配置 Key + if (!_jobDependency.TryGetValue(currentConfigKey, out var nextConfigKey) || string.IsNullOrEmpty(nextConfigKey)) + { + Log.Information("【任务监听】任务 {JobName} 是最后一个任务,本次任务链条执行完毕", currentJobKey.Name); + return; + } + + // 4. 触发下一个任务(标记为「依赖触发」,立即执行) + Log.Information("【任务监听】准备触发下一个任务: {NextJobName}(依赖触发)", nextConfigKey); + var triggerSuccess = await _jobService.StartJobAsync(nextConfigKey, "", isDependencyTrigger: true); + + if (triggerSuccess) + { + Log.Information("【任务监听】下一个任务 {NextJobName} 触发成功", nextConfigKey); + } + else + { + Log.Error("【任务监听】下一个任务 {NextJobName} 触发失败,任务链条中断", nextConfigKey); + } + } + } +} diff --git a/job/DouYinCollectSyncJob.cs b/job/DouYinCollectSyncJob.cs index 2f028e0..3a54028 100644 --- a/job/DouYinCollectSyncJob.cs +++ b/job/DouYinCollectSyncJob.cs @@ -15,9 +15,8 @@ namespace dy.net.job DouyinCommonService douyinCommonService,DouyinFollowService douyinFollowService,DouyinMergeVideoService douyinMergeVideoService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService,douyinFollowService, douyinMergeVideoService) { } - protected override string JobType => SystemStaticUtil.DY_COLLECTS; - - protected override VideoTypeEnum VideoType => VideoTypeEnum.Collect; + + protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_collects; protected override async Task BeforeProcessCookies() { @@ -61,34 +60,31 @@ namespace dy.net.job return Path.Combine(cookie.SavePath, "author"); } - protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount) + protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount,DouyinFollowed followed) { if (syncCount > 0) { - Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频"); + Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],本次共同步成功{syncCount}条视频"); cookie.CollHasSyncd = 1; await douyinCookieService.UpdateAsync(cookie); } else { - Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次没有查询到新的视频"); + Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],没有可以同步的新视频"); } } protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item) { - return new VideoEntityDifferences - { - VideoType = VideoTypeEnum.Collect, - }; + return new VideoEntityDifferences(); } protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed) { var (tag1, _, _) = GetVideoTags(item); - var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizePath(tag1); - var folder = Path.Combine(cookie.SavePath, safeTag1, $"{DouyinFileNameHelper.SanitizePath(item.Desc)}@{item.AwemeId}"); + var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizeLinuxFileName(tag1); + var folder = Path.Combine(cookie.SavePath, safeTag1, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}@{item.AwemeId}"); if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); return folder; } diff --git a/job/DouYinFavoritSyncJob.cs b/job/DouYinFavoritSyncJob.cs index ab7e71b..59b1ec4 100644 --- a/job/DouYinFavoritSyncJob.cs +++ b/job/DouYinFavoritSyncJob.cs @@ -17,9 +17,8 @@ namespace dy.net.job DouyinCommonService douyinCommonService,DouyinFollowService douyinFollowService,DouyinMergeVideoService douyinMergeVideoService) : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, douyinMergeVideoService) { } - protected override string JobType => SystemStaticUtil.DY_FAVORITES; - protected override VideoTypeEnum VideoType => VideoTypeEnum.Favorite; + protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_favorite; protected override async Task> GetValidCookies() { @@ -54,33 +53,25 @@ namespace dy.net.job return Path.Combine(cookie.FavSavePath, "author"); } - protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount) + protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount, DouyinFollowed followed) { if (syncCount > 0) { - Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频"); + Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],本次共同步成功{syncCount}条视频"); cookie.FavHasSyncd = 1; await douyinCookieService.UpdateAsync(cookie); } else { - Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次没有查询到新的视频"); + Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],没有可以同步的新视频"); } } - protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item) - { - return new VideoEntityDifferences - { - VideoType = VideoTypeEnum.Favorite - }; - } - protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed) { var (tag1, _, _) = GetVideoTags(item); - var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizePath(tag1); - var folder = Path.Combine(cookie.FavSavePath, safeTag1, $"{DouyinFileNameHelper.SanitizePath(item.Desc)}@{item.AwemeId}"); + var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizeLinuxFileName(tag1); + var folder = Path.Combine(cookie.FavSavePath, safeTag1, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}@{item.AwemeId}"); if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); return folder; } diff --git a/job/DouyinBasicSyncJob.cs b/job/DouyinBasicSyncJob.cs index d267c78..f061354 100644 --- a/job/DouyinBasicSyncJob.cs +++ b/job/DouyinBasicSyncJob.cs @@ -82,7 +82,7 @@ namespace dy.net.job /// 任务类型名称,用于日志记录和区分不同的同步任务 /// 子类必须实现此属性并返回具体的任务类型 /// - protected abstract string JobType { get; } + //protected abstract string VideoType { get; } protected abstract VideoTypeEnum VideoType { get; } @@ -131,14 +131,13 @@ namespace dy.net.job var config = douyinCommonService.GetConfig(); if (config == null) { - Log.Debug($"{JobType}-未获取到系统配置,任务终止!!!"); + Log.Debug($"{VideoType}-未获取到系统配置,任务终止!!!"); return; } - - // 2. 从配置中获取每页请求数量 - if (config.BatchCount > 0) - count = config.BatchCount.ToString(); + // 2. 从配置中获取每页请求数量--固定18 + //if (config.BatchCount > 0) + // count = config.BatchCount.ToString(); // 3. 在处理Cookie之前执行的预处理操作 await BeforeProcessCookies(); @@ -147,11 +146,11 @@ namespace dy.net.job var cookies = await GetValidCookies(); if (cookies == null || !cookies.Any()) { - Log.Debug($"{JobType}-无有效Cookie,任务终止!!!"); + Log.Debug($"{VideoType}-无有效Cookie,任务终止!!!"); return; } - Log.Debug($"{JobType}-共发现{cookies.Count}个Cookie,同步任务即将开始..."); + Log.Debug($"{VideoType}-共发现{cookies.Count}个Cookie,同步任务即将开始..."); // 6. 遍历每个有效的Cookie,执行同步操作 foreach (var cookie in cookies) @@ -262,8 +261,9 @@ namespace dy.net.job /// /// 用户Cookie /// 本次同步成功的视频数量 + /// /// 一个表示异步操作的任务 - protected abstract Task HandleSyncCompletion(DouyinCookie cookie, int syncCount); + protected abstract Task HandleSyncCompletion(DouyinCookie cookie, int syncCount,DouyinFollowed followed=null); /// /// 获取视频实体的差异信息 @@ -272,7 +272,10 @@ namespace dy.net.job /// 用户Cookie /// 视频信息 /// 视频实体的差异信息,包含视频类型和简化标题 - protected abstract VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item); + protected virtual VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item) + { + return new VideoEntityDifferences(); + } /// /// 获取NFO文件中的图片(如海报)文件名 @@ -302,13 +305,13 @@ namespace dy.net.job // 检查Cookie是否有效 if (!IsCookieValid(cookie)) { - Log.Debug($"{JobType}-Cookie[{cookie.UserName}]无效,任务终止!!!"); + Log.Debug($"{VideoType}-Cookie[{cookie.UserName}]无效,任务终止!!!"); return; } - Log.Debug($"{JobType}- Cookie-[{cookie.UserName}]开始同步..."); + Log.Debug($"{VideoType}- Cookie-[{cookie.UserName}]开始同步..."); //up主上传视频特殊处理 - if (JobType == SystemStaticUtil.DY_FOLLOWEDS) + if (VideoType ==VideoTypeEnum.dy_follows) { int syncCount = 0; // 本次同步成功的视频数量 string cursor = "0"; // 初始游标 @@ -324,18 +327,18 @@ namespace dy.net.job } if (string.IsNullOrWhiteSpace(firstUp.SecUid)) { - Log.Debug($"{JobType}-Cookie[{firstUp.UperName}]无效,没有sec_userid,任务终止!!!"); + Log.Debug($"{VideoType}-Cookie[{firstUp.UperName}]无效,没有sec_userid,任务终止!!!"); return; } - foreach (var item in follows) + foreach (var followed in follows) { cursor = "0"; // 初始游标 - await GetViedos(cookie, config, syncCount, cursor, hasMore, item); + await GetViedos(cookie, config, syncCount, cursor, hasMore, followed); hasMore = true; // 处理同步完成后的操作 - await HandleSyncCompletion(cookie, syncCount); + await HandleSyncCompletion(cookie, syncCount,followed); } } else @@ -353,7 +356,7 @@ namespace dy.net.job } catch (Exception ex) { - Log.Error(ex, $"{JobType}-Cookie[{cookie.Id}]同步出错!!!"); + Log.Error(ex, $"{VideoType}-Cookie[{cookie.Id}]同步出错!!!"); } } @@ -366,7 +369,7 @@ namespace dy.net.job var data = await FetchVideoData(cookie, cursor, followed == null ? "" : followed?.SecUid); if (data == null) { - Log.Debug($"{JobType}-Cookie[{cookie.UserName}]读取数据失败!!!"); + Log.Debug($"{VideoType}-Cookie[{cookie.UserName}]读取数据失败!!!"); break; } @@ -387,7 +390,7 @@ namespace dy.net.job //当syncCount达到上限时,跳出循环 if (config.BatchCount > 0 && syncCount >= config.BatchCount) { - Log.Debug($"{JobType}-Cookie[{cookie.UserName}]本次同步达到上限{config.BatchCount},停止同步!!!"); + Log.Debug($"{VideoType}-Cookie[{cookie.UserName}]本次同步达到上限{config.BatchCount},停止同步!!!"); break; } @@ -412,12 +415,28 @@ namespace dy.net.job var videos = new List(); foreach (var item in data.AwemeList) { + + //判断视频是否是强制删除且不再下载的视频 + var deleteVideo = await douyinCommonService.ExistDeleteVideo(item.AwemeId); + if (deleteVideo) + { + Log.Debug($"{VideoType}-视频-{item.AwemeId}-[{item.Desc}]已被标记为强制删除,跳过下载"); + continue; + } + //判断是否存在视频,是否根据去重规则进行去重处理。。 - bool Goon = await AutoDistinct(config, item); + // 1. 查询数据库中是否已存在该视频(通过 AwemeId 唯一标识) + var exitVideo = await douyinVideoService.GetByAwemeId(item.AwemeId); + + bool Goon = await AutoDistinct(config, item, cookie, exitVideo); if (!Goon) { continue; } + + if (exitVideo!=null) { + await douyinVideoService.DeleteById(exitVideo.Id); + } var uper = await douyinFollowService.GetByUperId(item.AuthorUserId.ToString(), cookie.MyUserId); if (uper != null) { @@ -442,13 +461,11 @@ namespace dy.net.job return videos; } - private async Task AutoDistinct(AppConfig config, Aweme item) + private async Task AutoDistinct(AppConfig config, Aweme item,DouyinCookie cookie,DouyinVideo exitVideo) { // 去重,检查视频是否已存在(按优先级下载) if (config.AutoDistinct) { - // 1. 查询数据库中是否已存在该视频(通过 AwemeId 唯一标识) - var exitVideo = await douyinVideoService.GetByAwemeId(item.AwemeId); if (exitVideo != null) { // 2. 已存在视频:先判断本地文件是否存在 @@ -465,7 +482,7 @@ namespace dy.net.job } catch (Exception ex) { - Log.Error($"解析优先级配置失败:{ex.Message}", ex); + Log.Error($"{VideoType}-解析优先级配置失败:{ex.Message}", ex); priLevs = new List(); // 解析失败则用默认优先级 } } @@ -497,23 +514,24 @@ namespace dy.net.job if (exitVideoType == currentVideoType) { // 已存在同优先级视频 → 跳过下载(避免重复) - Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(同最高优先级),跳过"); + //Log.Debug($"{VideoType}-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(同最高优先级),跳过"); return false; } else { // 已存在「低优先级」视频 → 替换(删除旧文件,继续下载新的最高优先级视频) - Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(低优先级:{exitVideoType}),替换为最高优先级:{currentVideoType}"); + //Log.Debug($"{VideoType}-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(低优先级:{exitVideoType}),替换为最高优先级:{currentVideoType}"); // 删除旧的低优先级文件(可选:也可保留备份,根据需求调整) try { - File.Delete(exitVideo.VideoSavePath); - Log.Debug($"已删除旧文件:{exitVideo.VideoSavePath}"); + //File.Delete(exitVideo.VideoSavePath); + DeleteOldViedo(config, exitVideo); + //Log.Debug($"已删除旧文件:{exitVideo.VideoSavePath}"); } catch (Exception ex) { - Log.Error($"删除旧文件失败:{ex.Message}", ex); + Log.Error($"{VideoType}-删除旧文件失败:{ex.Message}", ex); // 即使删除失败,仍继续下载(新文件会覆盖旧文件,或按路径规则重命名) } @@ -526,7 +544,7 @@ namespace dy.net.job if (exitVideoType == maxPriorityType) { // 已存在「最高优先级」视频 → 跳过(不替换最高优先级) - Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在最高优先级视频({maxPriorityType}),当前类型({currentVideoType})优先级低,跳过"); + //Log.Debug($"{VideoType}-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在最高优先级视频({maxPriorityType}),当前类型({currentVideoType})优先级低,跳过"); return false; } else @@ -539,39 +557,70 @@ namespace dy.net.job if (currentSort < exitSort) { // 当前类型优先级更高 → 替换旧视频 - Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在低优先级视频({exitVideoType}),替换为当前优先级:{currentVideoType}"); - + //Log.Debug($"{VideoType}-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在低优先级视频({exitVideoType}),替换为当前优先级:{currentVideoType}"); // 删除旧文件 - if (File.Exists(exitVideo.VideoSavePath)) - { - File.Delete(exitVideo.VideoSavePath); - } - + DeleteOldViedo(config, exitVideo); // 继续下载 } else { // 当前类型优先级更低或相等 → 跳过 - Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在更高/同等优先级视频({exitVideoType}),当前类型({currentVideoType})跳过"); + //Log.Debug($"{VideoType}-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在更高/同等优先级视频({exitVideoType}),当前类型({currentVideoType})跳过"); return false; } } } } - else + } + } + return true; + } + + private void DeleteOldViedo(AppConfig config, DouyinVideo exitVideo) + { + if (File.Exists(exitVideo.VideoSavePath)) + { + //如果是关注的视频,并且是保存到同一文件夹,则只删除文件 + if (VideoType == VideoTypeEnum.dy_follows && config.UperSaveTogether) + { + File.Delete(exitVideo.VideoSavePath); + //Log.Debug($"{VideoType}-已删除旧文件:{exitVideo.VideoSavePath}"); + //删除同名的.nfo 文件 + var nfoPath = Path.Combine(Path.GetDirectoryName(exitVideo.VideoSavePath), ".nfo"); + if (File.Exists(nfoPath)) { - // 数据库存在记录,但本地文件丢失 → 直接重新下载(无论优先级) - Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]数据库存在但本地文件丢失,重新下载"); + File.Delete(nfoPath); + //删除同名的封面文件 + //Log.Debug($"{VideoType}-已删除旧nfo文件:{nfoPath}"); + } + //删除同名的封面文件 + var posterPath = Path.Combine(Path.GetDirectoryName(exitVideo.VideoSavePath), "poster.jpg"); + if (File.Exists(posterPath)) + { + File.Delete(posterPath); + //Log.Debug($"{VideoType}-已删除旧封面文件:{posterPath}"); } } else { - // 数据库和本地均无该视频 → 直接下载 - //Log.Debug($"视频-{item.AwemeId}-[{item.Desc}]未存在,开始下载"); + //如果是关注的,且不是保存到同一文件夹 或者其他类型视频,直接删除文件夹 + // + var dirPath = Path.GetDirectoryName(exitVideo.VideoSavePath); + if (Directory.Exists(dirPath)) + { + Directory.Delete(dirPath, true); + //Log.Debug($"{VideoType}-已删除旧文件夹:{dirPath}"); + } + } + + //查看是否还有其他文件,如果没有则删除文件夹 + var parentDir = Path.GetDirectoryName(exitVideo.VideoSavePath); + if (Directory.Exists(parentDir) && !Directory.EnumerateFileSystemEntries(parentDir).Any()) + { + Directory.Delete(parentDir); + Log.Debug($"{VideoType}-已删除空文件夹:{parentDir}"); } } - - return true; } /// @@ -609,22 +658,22 @@ namespace dy.net.job // 如果文件已存在,跳过 if (File.Exists(savePath)) { - //Log.Debug($"{JobType}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]已存在,跳过下载."); + //Log.Debug($"{VideoType}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]已存在,跳过下载."); return null; } - Log.Debug($"{JobType}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]开始下载..."); + Log.Debug($"{VideoType}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}]开始下载..."); // 随机延迟,模拟人类操作 await Task.Delay(_random.Next(1, 4) * 1000); // 下载视频 if (!await douyinHttpClientService.DownloadAsync(videoUrl, savePath, cookie.Cookies)) { - Log.Error($"{JobType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]下载失败!!!"); + Log.Error($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}]下载失败!!!"); return null; } else { - Log.Debug($"{JobType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]下载完成."); + Log.Debug($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}]下载完成."); } // 下载视频封面 @@ -672,10 +721,10 @@ namespace dy.net.job // 检查图片保存路径是否配置 if (string.IsNullOrWhiteSpace(cookie.ImgSavePath)) { - Log.Error($"{JobType}-图文视频同步-没有配置图片存储路径,任务终止!!!"); + Log.Error($"{VideoType}-图文视频同步-没有配置图片存储路径,任务终止!!!"); return null; } - fileNamefolder = Path.Combine(cookie.ImgSavePath, DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId)); + fileNamefolder = Path.Combine(cookie.ImgSavePath, DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)); } else { @@ -713,7 +762,7 @@ namespace dy.net.job var mergeResult = await douyinMergeVideoService.MergeToVideo(cookie.Cookies, AppContext.BaseDirectory, reqParams, savePath, fileNamefolder, config.DownImageVideo, config.DownImage, config.DownMp3); if (!mergeResult) { - Log.Error($"{JobType}-图文视频-[{DouyinFileNameHelper.SanitizePath(item.Desc)}]合成失败!!!"); + Log.Error($"{VideoType}-图文视频-[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}]合成失败!!!"); return null; } @@ -722,13 +771,13 @@ namespace dy.net.job // 检查合成后的视频文件是否有效 if (!File.Exists(savePath) || new FileInfo(savePath).Length <= 0) { - Log.Error($"{JobType}-图文视频-[{DouyinFileNameHelper.SanitizePath(item.Desc)}]合成失败!!!"); + Log.Error($"{VideoType}-图文视频-[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}]合成失败!!!"); // 清理无效的文件和文件夹 if (Directory.Exists(fileNamefolder)) { File.Delete(savePath); Directory.Delete(fileNamefolder, true); - Log.Error($"{JobType}-图文视频-删除合成失败的视频文件和目录..."); + Log.Error($"{VideoType}-图文视频-删除合成失败的视频文件和目录..."); } return null; } @@ -765,13 +814,14 @@ namespace dy.net.job // 特殊处理合成视频的字段 videoEntity.FileHash = string.Empty; // 合成视频没有原始文件哈希 videoEntity.VideoUrl = "/"; // 合成视频没有原始URL - videoEntity.ViedoType = VideoTypeEnum.ImageVideo; // 标记为图片合成视频 + videoEntity.ViedoType = VideoType; + videoEntity.IsMergeVideo = 1;// 标记为图片合成视频 return videoEntity; } catch (Exception ex) { - Log.Error(ex, $"{JobType}-图片视频同步-处理图片集并合成视频时出错"); + Log.Error(ex, $"{VideoType}-图片视频同步-处理图片集并合成视频时出错"); return null; } } @@ -788,30 +838,11 @@ namespace dy.net.job try { await douyinVideoService.BatchInsertOrUpdate(videos); - - var redowns = await douyinCommonService.GetAllRedown(); - if (redowns != null && redowns.Any()) - { - //找出viedos和redowns重复的 - var duplicateVideos = videos.Where(v => redowns.Any(r => r.ViedoId == v.AwemeId)).ToList(); - if (duplicateVideos != null && duplicateVideos.Any()) - { - var duplicateVideosIds = duplicateVideos.Select(x => x.AwemeId).ToList(); - var downeds = redowns.Where(x => duplicateVideosIds.Contains(x.ViedoId))?.ToList(); - foreach (var item in downeds) - { - item.Status = 1; - var v = videos.FirstOrDefault(x => x.AwemeId == item.ViedoId); - Log.Debug($"{JobType}-重新下载成功:-{v.VideoTitle}"); - } - await douyinCommonService.UpdateRedownStatus(downeds); - } - } return videos.Count; } catch (Exception ex) { - Log.Error(ex, $"{JobType}-批量保存视频到数据库失败"); + Log.Error(ex, $"{VideoType}-批量保存视频到数据库失败"); // 清理保存失败的视频文件 await CleanupFailedVideos(videos); return 0; @@ -938,7 +969,7 @@ namespace dy.net.job /// 一个表示异步操作的任务 private async Task CleanupFailedVideos(List videos) { - Log.Debug($"{JobType}-数据库保存失败,开始清理本次下载的视频目录..."); + Log.Debug($"{VideoType}-数据库保存失败,开始清理本次下载的视频目录..."); foreach (var video in videos) { @@ -956,12 +987,12 @@ namespace dy.net.job { Directory.Delete(directory); } - Log.Debug($"{JobType}-清理失败视频文件成功: {video.VideoSavePath}!!!"); + Log.Debug($"{VideoType}-清理失败视频文件成功: {video.VideoSavePath}!!!"); } catch (Exception ex) { - Log.Warning(ex, $"{JobType}-清理失败视频文件出错: {video.VideoSavePath}!!!"); + Log.Warning(ex, $"{VideoType}-清理失败视频文件出错: {video.VideoSavePath}!!!"); } }); } @@ -1015,7 +1046,7 @@ namespace dy.net.job return new DouyinVideo { - ViedoType = diffs.VideoType, + ViedoType = VideoType, AwemeId = item.AwemeId, Author = item.Author?.Nickname, AuthorId = item.Author?.Uid, @@ -1053,7 +1084,7 @@ namespace dy.net.job /// /// 视频类型 /// - public VideoTypeEnum VideoType { get; set; } + //public VideoTypeEnum VideoType { get; set; } /// /// 简化的视频标题 diff --git a/job/DouyinFollowedUsersSyncJob.cs b/job/DouyinFollowedUsersSyncJob.cs index ccc8fe5..9dab692 100644 --- a/job/DouyinFollowedUsersSyncJob.cs +++ b/job/DouyinFollowedUsersSyncJob.cs @@ -84,10 +84,10 @@ namespace dy.net.job if (follows.Count > 0) { - await _followService.Sync(follows, ck.MyUserId); + await _followService.Sync(follows, ck ); } - Serilog.Log.Debug($"当前Cookie-[{ck.UserName}],本次同步关注列表完成,共同步关注{total}人。"); + //Serilog.Log.Debug($"当前Cookie-[{ck.UserName}],本次同步关注列表完成,当前共关注{total}人。"); } } } diff --git a/job/DouyinFollowedViedoSyncJob.cs b/job/DouyinFollowedViedoSyncJob.cs index d06641c..169a226 100644 --- a/job/DouyinFollowedViedoSyncJob.cs +++ b/job/DouyinFollowedViedoSyncJob.cs @@ -16,9 +16,8 @@ namespace dy.net.job { } - protected override string JobType => SystemStaticUtil.DY_FOLLOWEDS; - protected override VideoTypeEnum VideoType => VideoTypeEnum.UperPost; + protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_follows; protected override async Task> GetValidCookies() { @@ -56,7 +55,7 @@ namespace dy.net.job protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed) { #region 默认使用UP主名称作为文件夹名称,若关注列表中有自定义保存路径则使用自定义路径 - var authorName = string.IsNullOrWhiteSpace(item.Author?.Nickname) ? "UnknownAuthor" : DouyinFileNameHelper.SanitizePath(item.Author.Nickname); + var authorName = string.IsNullOrWhiteSpace(item.Author?.Nickname) ? "UnknownAuthor" : DouyinFileNameHelper.SanitizeLinuxFileName(item.Author.Nickname); var folder = Path.Combine(cookie.UpSavePath, authorName); if (!string.IsNullOrWhiteSpace(followed.SavePath)) { @@ -71,7 +70,7 @@ namespace dy.net.job } else { - var sampleName = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId); + var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId); var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result; var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName; return Path.Combine(folder, fileNameFolder); @@ -115,7 +114,7 @@ namespace dy.net.job string fileName; if (config?.UperUseViedoTitle ?? false)//优先 { - var sampleName = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId); + var sampleName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId); var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result; fileName = string.IsNullOrWhiteSpace(existingName) ? $"{sampleName}.{Format}" : $"{existingName}.{Format}"; } @@ -130,11 +129,11 @@ namespace dy.net.job Id = item.AwemeId, ReleaseTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime), Resolution = $"{Width}×{Height}", - VideoTitle = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId), + VideoTitle = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId), Author = item.Author.Nickname }); - fileName= $"{DouyinFileNameHelper.SanitizePath(fullName)}.{Format}"; + fileName= $"{DouyinFileNameHelper.SanitizeLinuxFileName(fullName)}.{Format}"; } else { @@ -172,17 +171,17 @@ namespace dy.net.job return Path.Combine(cookie.UpSavePath, "author"); } - protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount) + protected override async Task HandleSyncCompletion(DouyinCookie cookie, int syncCount, DouyinFollowed followed) { if (syncCount > 0) { - Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频"); + Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],本次共同步成功{syncCount}条视频"); cookie.UperSyncd = 1; await douyinCookieService.UpdateAsync(cookie); } else { - Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次没有查询到新的视频"); + Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}]-{(followed == null ? "" : $"{followed.UperName}")},没有可以同步的新视频"); } } @@ -193,12 +192,10 @@ namespace dy.net.job if (config?.UperUseViedoTitle ?? false) { - simplifiedTitle = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId); + simplifiedTitle = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId); } - return new VideoEntityDifferences { - VideoType = VideoTypeEnum.UperPost, VideoTitleSimplify = simplifiedTitle }; } diff --git a/model/ViedoReDown.cs b/model/DouyinReDownload.cs similarity index 96% rename from model/ViedoReDown.cs rename to model/DouyinReDownload.cs index 0b3658d..8d41fa5 100644 --- a/model/ViedoReDown.cs +++ b/model/DouyinReDownload.cs @@ -6,7 +6,7 @@ namespace dy.net.model /// 重新下载的列表 /// [SugarTable(TableName = "dy_rd_video")] - public class ViedoReDown + public class DouyinReDownload { /// /// diff --git a/model/DouyinVideo.cs b/model/DouyinVideo.cs index cf6bb89..ff22f79 100644 --- a/model/DouyinVideo.cs +++ b/model/DouyinVideo.cs @@ -139,6 +139,11 @@ namespace dy.net.model [SugarColumn(Length =200,IsNullable =true)] public VideoTypeEnum ViedoType { get; set; } + /// + /// 是否合成视频 + /// + public int IsMergeVideo { get; set; } + [SugarColumn(IsIgnore = true)] public string ViedoTypeStr => ViedoType.GetDescription(); diff --git a/model/DouyinVideoDelete.cs b/model/DouyinVideoDelete.cs new file mode 100644 index 0000000..c8acff2 --- /dev/null +++ b/model/DouyinVideoDelete.cs @@ -0,0 +1,24 @@ +using SqlSugar; + +namespace dy.net.model +{ + /// + /// 永久删除的视频记录-不再下载 + /// + [SugarTable(TableName = "dy_delete_video")] + public class DouyinVideoDelete + { + /// + /// + /// + [SugarColumn(IsPrimaryKey = true)] + public string Id { get; set; } + /// + /// 原视频Id + /// + [SugarColumn(IsNullable =true,Length =50)] + public string ViedoId { get; set; } + + public DateTime DeleteTime { get; set; } + } +} diff --git a/repository/DouyinFollowRepository.cs b/repository/DouyinFollowRepository.cs index 537e375..359dde5 100644 --- a/repository/DouyinFollowRepository.cs +++ b/repository/DouyinFollowRepository.cs @@ -93,11 +93,11 @@ namespace dy.net.repository /// /// /// - public async Task Sync(List followInfos, string myselfUserId) + public async Task Sync(List followInfos, DouyinCookie ck) { // 基础参数校验 if (followInfos == null) followInfos = new List(); - if (string.IsNullOrWhiteSpace(myselfUserId)) + if (ck==null || string.IsNullOrWhiteSpace(ck.MyUserId)) { Serilog.Log.Error("同步关注列表失败:当前用户ID为空"); return false; @@ -107,7 +107,7 @@ namespace dy.net.repository { // 1. 查询现有关注列表 List existFollows = await Db.Queryable() - .Where(x => x.mySelfId == myselfUserId) + .Where(x => x.mySelfId == ck.MyUserId) .Where(x=>!x.IsNoFollowed) //排除手动添加但未关注的用户 .ToListAsync() ?? new List(); @@ -161,7 +161,7 @@ namespace dy.net.repository Id = IdGener.GetLong().ToString(), Enterprise = follow.EnterpriseVerifyReason, LastSyncTime = DateTime.UtcNow, - mySelfId = myselfUserId, + mySelfId = ck.MyUserId, SecUid = follow.SecUid, OpenSync = false, UperAvatar = follow.Avatar?.UrlList?.FirstOrDefault() ?? "", @@ -201,8 +201,6 @@ namespace dy.net.repository Serilog.Log.Error("同步关注列表失败:关注信息更新异常"); return false; } - - Serilog.Log.Debug($"同步关注列表:成功更新{toUpdateFollows.Count}条关注信息(用户ID:{myselfUserId})"); } // 6. 分批处理删除(单批200条) @@ -212,7 +210,7 @@ namespace dy.net.repository async batch => { var secUids = batch.Select(x => x.SecUid).ToList(); - await DeleteAsync(x => x.mySelfId == myselfUserId && secUids.Contains(x.SecUid)); + await DeleteAsync(x => x.mySelfId == ck.MyUserId && secUids.Contains(x.SecUid)); return true; }); @@ -223,12 +221,12 @@ namespace dy.net.repository } } - Serilog.Log.Debug($"同步关注列表完成(用户ID:{myselfUserId}):新增{toAddFollows.Count}条,更新{toUpdateFollows.Count}条,删除{toRemoveFollows.Count}条"); + Serilog.Log.Debug($"dy_followed_users({ck.UserName})关注列表同步完成:新增{toAddFollows.Count}条,更新{toUpdateFollows.Count}条,删除{toRemoveFollows.Count}条"); return true; } catch (Exception ex) { - Serilog.Log.Error(ex, $"同步关注列表失败(用户ID:{myselfUserId}):{ex.Message}"); + Serilog.Log.Error(ex, $"同步关注列表失败({ck.UserName}):{ex.Message}"); return false; } } diff --git a/repository/DouyinVideoRepository.cs b/repository/DouyinVideoRepository.cs index 2005f6a..f640801 100644 --- a/repository/DouyinVideoRepository.cs +++ b/repository/DouyinVideoRepository.cs @@ -33,10 +33,11 @@ namespace dy.net.repository VideoTypeEnum? enumviedoType = null; - if (!string.IsNullOrEmpty(dto.ViedoType) && dto.ViedoType != "*") + if (!string.IsNullOrEmpty(dto.ViedoType) && dto.ViedoType != "*" && dto.ViedoType != "4") { enumviedoType = dto.ViedoType.ToVideoTypeEnum(); } + var where = this.Db.Queryable() //.WhereIF(!string.IsNullOrWhiteSpace(title), x => x.VideoTitle.Contains(title)) .WhereIF(!string.IsNullOrWhiteSpace(dto.Title), x => x.VideoTitle.Contains(dto.Title)) @@ -45,7 +46,8 @@ namespace dy.net.repository .WhereIF(end.HasValue, x => x.SyncTime <= end.Value) .WhereIF(start2.HasValue, x => x.CreateTime >= start2.Value) .WhereIF(end2.HasValue, x => x.CreateTime <= end2.Value) - .WhereIF(enumviedoType.HasValue, x => x.ViedoType == enumviedoType); + .WhereIF(enumviedoType.HasValue, x => x.ViedoType == enumviedoType) + .WhereIF(dto.ViedoType == "4", x => x.IsMergeVideo == 1); var totalCount = await where.CountAsync(); @@ -90,7 +92,7 @@ namespace dy.net.repository public async Task<(string, string)> GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify) { - var video = await this.Db.Queryable().Where(x => x.AuthorId == AuthorId && x.ViedoType == VideoTypeEnum.UperPost) + var video = await this.Db.Queryable().Where(x => x.AuthorId == AuthorId && x.ViedoType == VideoTypeEnum.dy_follows) .Where(x => x.VideoTitleSimplify == ViedoNameSimplify) .OrderByDescending(x => x.CreateTime).FirstAsync(); @@ -140,7 +142,7 @@ namespace dy.net.repository /// /// /// - public bool InsertReDowns(List downs) + public bool InsertReDowns(List downs) { if (downs != null) { @@ -169,9 +171,9 @@ namespace dy.net.repository } // 构建更新条件(统一Where条件,避免重复代码) - var updateable = Db.Updateable() + var updateable = Db.Updateable() .Where(it => it.Id == videoId) - .SetColumns(it => new ViedoReDown + .SetColumns(it => new DouyinReDownload { Status = status, UpdateTime = DateTime.Now @@ -197,9 +199,9 @@ namespace dy.net.repository /// /// /// - public async Task> GetViedoReDowns() + public async Task> GetViedoReDowns() { - return await this.Db.Queryable() + return await this.Db.Queryable() .Where(x => x.Status == 0 || x.Status == 2) .ToListAsync(); } diff --git a/service/DouyinCommonService.cs b/service/DouyinCommonService.cs index 47feaf5..c6596a6 100644 --- a/service/DouyinCommonService.cs +++ b/service/DouyinCommonService.cs @@ -49,7 +49,7 @@ namespace dy.net.service LogKeepDay = 10, UperSaveTogether = false,//博主视频:true-->每个视频单独一个文件夹 false-->所有视频放在同一个文件夹 UperUseViedoTitle = false,//博主视频:true-->使用视频标题作为文件名 false-->使用视频id作为文件名 - DownImageVideo = false,//默认不下载图文视频 + DownImageVideo = true,//默认下载图文视频 DownMp3 = false, DownImage = false, ImageViedoSaveAlone = true, @@ -99,18 +99,18 @@ namespace dy.net.service } /// - /// 兼容旧版将之前同步了的我收藏的数据进行更新 + /// 初始化一些数据,兼容旧版数据结构。 /// public void UpdateCollectViedoType() { - + //更新视频类型字段-兼容老版本 string sql = @"UPDATE dy_collect_video SET ViedoType = CASE WHEN ViedoType = '0' THEN 0 WHEN ViedoType = '1' THEN 1 WHEN ViedoType = '2' THEN 2 WHEN ViedoType = '3' THEN 3 - WHEN ViedoType = '4' THEN 4 + WHEN ViedoType = '4' THEN 4 ELSE NULL END;"; @@ -119,16 +119,53 @@ namespace dy.net.service //更新关注表的IsNoFollowed字段为空的数据为0--兼容老版本-新加的字段 string followUpdateSql = @"Update dy_follow SET IsNoFollowed=0 WHERE IsNoFollowed is NULL"; sqlSugarClient.Ado.ExecuteCommand(followUpdateSql); - //var collectViedos = sqlSugarClient.Queryable().ToList(); - //if (collectViedos.Any()) - //{ - // collectViedos.ForEach(x => - // { - // x.ViedoType = x.; - // }); - // sqlSugarClient.Updateable(collectViedos).ExecuteCommand(); - //} + //更新图片视频的合并状态 + string updateIsMergeVideoSql = @"UPDATE dy_collect_video SET IsMergeVideo = 1 WHERE IsMergeVideo IS NULL and ViedoType='4';"; + sqlSugarClient.Ado.ExecuteCommand(updateIsMergeVideoSql); + + //更新非图片视频的合并状态 + string updateNoIsMergeVideoSql = @"UPDATE dy_collect_video SET IsMergeVideo = 0 WHERE IsMergeVideo IS NULL and ViedoType<>'4';"; + sqlSugarClient.Ado.ExecuteCommand(updateNoIsMergeVideoSql); + + //强制开启去重 + sqlSugarClient.Updateable().SetColumns(x => new AppConfig { AutoDistinct = true }).Where(x => !string.IsNullOrWhiteSpace(x.Id)).ExecuteCommand(); + //重新根据保存路径更新图片视频的类型为喜欢,收藏或关注 + var collectViedos = sqlSugarClient.Queryable().Where(x=>x.ViedoType==VideoTypeEnum.ImageVideo).ToList(); + if (collectViedos != null && collectViedos.Any()) + { + var cookies= sqlSugarClient.Queryable().ToList(); + collectViedos.ForEach(x => + { + var ck= cookies.FirstOrDefault(c => c.Id == x.CookieId); + if (ck != null) + { + + var savePath = x.VideoSavePath; + if (savePath != null) + { + if (savePath.StartsWith(ck.SavePath)) + { + x.ViedoType = VideoTypeEnum.dy_collects; + } + else if(savePath.StartsWith(ck.UpSavePath)) + { + x.ViedoType = VideoTypeEnum.dy_follows; + } + else if(savePath.StartsWith(ck.FavSavePath)) + { + x.ViedoType = VideoTypeEnum.dy_favorite; + } + else + { + x.ViedoType= VideoTypeEnum.dy_collects; + } + } + } + }); + //只更新图片视频的类型 + sqlSugarClient.Updateable(collectViedos).UpdateColumns(x => new DouyinVideo { ViedoType = x.ViedoType }).IgnoreColumns(true).ExecuteCommand(); + } } /// /// 重置所有Cookie的同步状态为0 @@ -148,18 +185,28 @@ namespace dy.net.service } /// - /// 查询需要下载的所有重下载视频记录 + /// 查询是否已删除 /// + /// /// - public async Task> GetAllRedown() + public async Task ExistDeleteVideo(string videoId) { - return await sqlSugarClient.Queryable().Where(x => x.Status == 0 || x.Status == 2).ToListAsync(); + var count= await sqlSugarClient.Queryable().Where(x=>x.ViedoId==videoId).CountAsync(); + return count > 0; } - public async Task UpdateRedownStatus(List list) + /// + /// 新增要删除的视频 + /// + /// + /// + public async Task AddDeleteVideo(DouyinVideoDelete dto) { - return await sqlSugarClient.Updateable(list).ExecuteCommandAsync() > 0; + dto.Id=IdGener.GetLong().ToString(); + dto.DeleteTime = DateTime.Now; + return sqlSugarClient.Insertable(dto).ExecuteCommand() > 0; } + #region 测试创建数据库 ///// diff --git a/service/DouyinFollowService.cs b/service/DouyinFollowService.cs index 091291f..64935e0 100644 --- a/service/DouyinFollowService.cs +++ b/service/DouyinFollowService.cs @@ -55,11 +55,11 @@ namespace dy.net.service /// /// /// - /// + /// /// - public async Task Sync(List followInfos, string myselfUserId) + public async Task Sync(List followInfos, DouyinCookie ck) { - return await _followRepository.Sync(followInfos, myselfUserId); + return await _followRepository.Sync(followInfos, ck); } public async Task GetByUperId(string uperId,string myUid) diff --git a/service/DouyinHttpClientService.cs b/service/DouyinHttpClientService.cs index 1bbcd00..8fd0376 100644 --- a/service/DouyinHttpClientService.cs +++ b/service/DouyinHttpClientService.cs @@ -15,11 +15,14 @@ namespace dy.net.service public static readonly string DouYinApi = "https://www.douyin.com/aweme/v1/web/aweme"; // 随机数生成器(避免重复实例化,保证随机性) private readonly IHttpClientFactory _clientFactory; + // 下载信号量锁:初始计数1,最大并发1(同时只能一个下载任务) + private readonly SemaphoreSlim _downloadSemaphore = new SemaphoreSlim(1, 1); public DouyinHttpClientService(IHttpClientFactory clientFactory) { _clientFactory = clientFactory; } + /// /// 查询用户收藏的视频 /// @@ -371,6 +374,111 @@ namespace dy.net.service } } + /// + /// 下载文件并保存到本地(支持重试机制+单线程限制,同时只能一个下载) + /// + /// 文件地址 + /// 保存路径 + /// 请求Cookie + /// HttpClient名称(默认"dy_down1") + /// 取消令牌(用于终止任务) + /// 流读取超时时间(默认60秒) + /// 最大重试次数(默认3次) + /// 初始重试延迟(默认1秒,指数退避) + /// 是否下载成功 + //public async Task DownloadAsync( + // string videoUrl, + // string savePath, + // string cookie, + // CancellationToken cancellationToken = default, + // TimeSpan? streamTimeout = null, + // int maxRetryCount = 3, + // TimeSpan? initialRetryDelay = null) + //{ + // bool lockAcquired = false; + // try + // { + // // 申请锁:如果已有下载任务在执行,会阻塞等待直到锁释放 + // // 传入cancellationToken支持取消等待 + // await _downloadSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + // lockAcquired = true; // 标记锁已获取 + + // // 重试参数初始化 + // int retryCount = 0; + // var retryDelay = initialRetryDelay ?? TimeSpan.FromSeconds(1); + // streamTimeout ??= TimeSpan.FromSeconds(60); + + // while (true) + // { + // try + // { + // return await TryDownloadOnceAsync( + // videoUrl, savePath, cookie, cancellationToken, streamTimeout.Value); + // } + // catch (Exception ex) when (IsRetryableException(ex) && retryCount < maxRetryCount) + // { + // retryCount++; + // var delay = TimeSpan.FromMilliseconds(retryDelay.TotalMilliseconds * Math.Pow(2, retryCount - 1)); + // Serilog.Log.Warning(ex, $"下载失败(第{retryCount}/{maxRetryCount}次重试):{videoUrl},将在{delay.TotalSeconds:F1}秒后重试"); + + // try + // { + // await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + // } + // catch (OperationCanceledException) + // { + // Serilog.Log.Information($"重试等待被取消:{videoUrl}"); + // CleanupIncompleteFile(savePath); + // return false; + // } + // } + // catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + // { + // Serilog.Log.Information($"下载被取消:{videoUrl}"); + // CleanupIncompleteFile(savePath); + // return false; + // } + // catch (Exception ex) + // { + // Serilog.Log.Error(ex, $"下载失败(不可重试):{videoUrl}"); + // CleanupIncompleteFile(savePath); + // return false; + // } + // } + // } + // finally + // { + // // 确保锁一定释放(无论是否发生异常) + // if (lockAcquired) + // { + // _downloadSemaphore.Release(); + // Serilog.Log.Debug($"下载锁已释放,下一个任务可执行"); + // } + // } + //} + + // 辅助类:用于using语句自动释放SemaphoreSlim + //public sealed class SemaphoreReleaser : IDisposable + //{ + // private readonly SemaphoreSlim _semaphore; + // private bool _disposed; + + // public SemaphoreReleaser(SemaphoreSlim semaphore) + // { + // _semaphore = semaphore ?? throw new ArgumentNullException(nameof(semaphore)); + // } + + // public void Dispose() + // { + // if (!_disposed) + // { + // _semaphore.Release(); // 释放锁,允许下一个任务执行 + // _disposed = true; + // } + // } + //} + + /// /// 单次下载尝试(核心下载逻辑) /// diff --git a/service/DouyinMergeVideoService.cs b/service/DouyinMergeVideoService.cs index 2d49c98..4f23213 100644 --- a/service/DouyinMergeVideoService.cs +++ b/service/DouyinMergeVideoService.cs @@ -370,7 +370,7 @@ namespace dy.net.service // 延迟清理:给 FFmpeg 进程足够时间释放文件句柄(1秒) await Task.Delay(1000); Directory.Delete(tempDir, recursive: true); - Log.Debug($"临时目录已清理:{tempDir}"); + //Log.Debug($"临时目录已清理:{tempDir}"); } catch (Exception ex) { diff --git a/service/DouyinQuartzJobService.cs b/service/DouyinQuartzJobService.cs index ad19246..5f6b98d 100644 --- a/service/DouyinQuartzJobService.cs +++ b/service/DouyinQuartzJobService.cs @@ -1,6 +1,7 @@ using dy.net.dto; using dy.net.job; using Quartz; +using Quartz.Impl.Matchers; using Serilog; using System; using System.Threading.Tasks; @@ -13,242 +14,21 @@ namespace dy.net.service public class DouyinQuartzJobService { private readonly ISchedulerFactory _schedulerFactory; - private const string DefaultJobGroup = "group1"; + private const string DefaultJobGroup = "dysync.net"; private const int DefaultIntervalMinutes = 30; private const int DefaultCronStartDelaySeconds = 30; private const int DefaultSimpleStartDelaySeconds = 3; - - - public DouyinQuartzJobService(ISchedulerFactory schedulerFactory) + // 任务顺序依赖配置(核心:定义执行顺序,供 Listener 使用) + public Dictionary JobDependency { get; } = new() { - _schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory)); - } + {"collect", "favorite"}, // collect 执行完 → 触发 favorite + {"favorite", "followed"}, // favorite 执行完 → 触发 followed + {"followed", null}, // followed 执行完 → 一轮任务结束 + }; - /// - /// 启动所有抖音相关定时任务 - /// - /// Cron表达式或间隔分钟数 - /// 任务之间的启动延迟(毫秒) - /// 是否启动成功 - public async Task InitOrReStartAllJobs(string expression, int delayBetweenJobs = 5000) - { - if (string.IsNullOrWhiteSpace(expression)) - { - Log.Warning("定时任务表达式为空,使用默认配置"); - expression = DefaultIntervalMinutes.ToString(); - } - - // 按顺序启动任务,避免并发 - var jobTasks = new List> - { - //关注列表 - //StartJobAsync("follow_user", expression), - //我收藏的作品 - StartJobAsync("collect", expression), - //我喜欢的作品 - DelayAndStartJobAsync("favorite", expression, delayBetweenJobs), - //关注的用户的作品 - DelayAndStartJobAsync("uper", expression, delayBetweenJobs * 2), - //关注列表 - DelayAndStartJobAsync("follow_user", (Convert.ToInt32(expression)*2*24).ToString(), delayBetweenJobs * 3) - }; - - var results = await Task.WhenAll(jobTasks); - return results.All(success => success); - } - - /// - /// 启动关注同步任务(单次执行) - /// - /// 是否启动成功 - public async Task StartFollowJobOnceAsync() - { - return await StartOneTimeJobAsync("follow_user_once"); - } - - - /// - /// 延迟后启动任务 - /// - private async Task DelayAndStartJobAsync(string jobKey, string expression, int delayMs) - { - if (delayMs > 0) - { - await Task.Delay(delayMs); - } - - // 如果是数字表达式,自动递增避免并发 - var adjustedExpression = AdjustExpressionForConcurrency(jobKey, expression); - return await StartJobAsync(jobKey, adjustedExpression); - } - - /// - /// 调整任务表达式以避免并发 - /// - private string AdjustExpressionForConcurrency(string jobKey, string expression) - { - if (!int.TryParse(expression, out int interval)) - return expression; - - // 根据任务类型递增间隔,避免所有任务同时执行 - var jobIndex = _jobConfigs.Keys.ToList().IndexOf(jobKey); - return (interval + jobIndex).ToString(); - } - - /// - /// 启动指定定时任务 - /// - private async Task StartJobAsync(string configKey, string expression) - { - 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) - .Build(); - - // 创建触发器 - var trigger = CreateTrigger(triggerKey, expression, jobConfig.Description); - if (trigger == null) - { - Log.Error("创建触发器失败: {JobDescription}", jobConfig.Description); - return false; - } - - // 调度任务 - await scheduler.ScheduleJob(jobDetail, trigger); - Log.Information("启动定时任务成功 - {JobDescription}, 表达式: {Expression}", - jobConfig.Description, expression); - - return true; - } - catch (Exception ex) - { - Log.Error(ex, "启动定时任务失败 - {JobDescription}", jobConfig.Description); - return false; - } - } - - /// - /// 启动单次执行任务 - /// - private async Task 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) - .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; - } - } - - /// - /// 创建触发器(支持Cron表达式和简单间隔) - /// - private ITrigger? CreateTrigger(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(); - } - - /// - /// 移除已存在的任务 - /// - private async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey) - { - if (await scheduler.CheckExists(jobKey)) - { - Log.Information("移除已存在的任务: {JobKey}", jobKey); - await scheduler.DeleteJob(jobKey); - } - } - /// - /// 任务配置信息 - /// - private readonly Dictionary _jobConfigs = new() + // 任务配置信息(保持原有配置不变,改为 public 供 Listener 访问) + public Dictionary JobConfigs { get; } = new() { { "collect", @@ -267,11 +47,11 @@ namespace dy.net.service "抖音点赞同步任务") }, { - "uper", + "followed", new JobConfig( typeof(DouyinFollowedViedoSyncJob), - "dy.job.key.uper", - "dy.trigger.key.uper", + "dy.job.key.followed", + "dy.trigger.key.followed", "抖音UP主作品同步任务") }, { @@ -290,16 +70,270 @@ namespace dy.net.service "dy.trigger.key.follow_user_once", "抖音关注同步任务(单次执行)") } - , - //{ - // "redown_once", - // new JobConfig( - // typeof(DouyinReDownSyncJob), - // "dy.job.key.redown_once", - // "dy.trigger.key.redown_once", - // "抖音重新下载任务(单次执行)") - //} }; - } + public DouyinQuartzJobService(ISchedulerFactory schedulerFactory) + { + _schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory)); + } + + /// + /// 启动所有抖音相关定时任务(顺序执行模式) + /// + /// Cron表达式或间隔分钟数(控制整个链条的执行频率) + /// 是否启动成功 + public async Task 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 → follow_user", expression); + } + else + { + Log.Error("【任务服务】任务链条启动失败(第一个任务 {FirstJob} 启动失败)", firstJobConfigKey); + } + //启动follow_user--这个与其他几个任务没有依赖关系,所以单独启动 + await StartJobAsync("follow_user", expression); + return startSuccess; + } + catch (Exception ex) + { + Log.Error(ex, "【任务服务】初始化任务链条异常"); + return false; + } + } + + /// + /// 启动关注同步任务(单次执行) + /// + public async Task StartFollowJobOnceAsync() + { + return await StartOneTimeJobAsync("follow_user_once"); + } + + /// + /// 注册独立的 JobListener(核心步骤) + /// + private async Task RegisterJobListener(IScheduler scheduler) + { + // 创建独立的 Listener 实例,注入依赖(任务配置、依赖关系、当前服务) + var dependencyListener = new DouyinJobDependencyListener( + JobConfigs, // 任务配置 + JobDependency, // 依赖顺序 + this // 任务服务(用于触发下一个任务) + ); + + // 注册 Listener:仅监听 DefaultJobGroup 分组的任务(精准匹配,避免影响其他任务) + scheduler.ListenerManager.AddJobListener( + dependencyListener, + GroupMatcher.GroupEquals(DefaultJobGroup) + ); + + Log.Information("【任务服务】JobListener 注册成功:{ListenerName}", dependencyListener.Name); + } + + /// + /// 移除所有已存在的任务(避免重复调度) + /// + 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); + } + } + } + + /// + /// 启动指定定时任务(public 修饰,供 Listener 调用) + /// + /// 任务配置Key(如:collect、favorite) + /// 定时表达式(依赖触发时传空) + /// 是否为依赖触发(true=立即执行,false=定时执行) + /// 是否启动成功 + public async Task 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; + } + } + + /// + /// 启动单次执行任务(保持原有逻辑不变) + /// + private async Task 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; + } + } + + /// + /// 创建「定时触发器」(按表达式执行,仅第一个任务使用) + /// + 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(); + } + + /// + /// 创建「依赖触发器」(立即执行,仅执行一次) + /// + private ITrigger CreateDependencyTrigger(TriggerKey triggerKey, string jobDescription) + { + return TriggerBuilder.Create() + .WithIdentity(triggerKey) + .WithDescription($"{jobDescription} - 依赖触发(立即执行)") + .StartNow() // 立即触发 + .Build(); + } + + /// + /// 移除已存在的任务(保持原有逻辑不变) + /// + private async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey) + { + if (await scheduler.CheckExists(jobKey)) + { + Log.Information("【任务服务】移除已存在的任务: {JobKey}", jobKey); + await scheduler.DeleteJob(jobKey); + } + } + +} } \ No newline at end of file diff --git a/service/DouyinVideoService.cs b/service/DouyinVideoService.cs index 18a1720..eae2fa6 100644 --- a/service/DouyinVideoService.cs +++ b/service/DouyinVideoService.cs @@ -22,6 +22,11 @@ namespace dy.net.service } + public async Task DeleteById(string Id) + { + return await _dyCollectVideoRepository.DeleteByIdAsync(Id); + } + public async Task BatchInsertOrUpdate(List videos) { // 边界处理:传入列表为空直接返回成功 @@ -99,22 +104,22 @@ namespace dy.net.service CategoryCount = list.Select(x => x.Tag1).Distinct().Count(), VideoCount = list.Count, Categories = Categories, - FavoriteCount = list.Count(x => x.ViedoType == VideoTypeEnum.Favorite), - CollectCount = list.Count(x => x.ViedoType == VideoTypeEnum.Collect), - FollowCount = list.Count(x => x.ViedoType == VideoTypeEnum.UperPost), - GraphicVideoCount = list.Count(x => x.ViedoType == VideoTypeEnum.ImageVideo), + FavoriteCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_favorite), + CollectCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_collects), + FollowCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_follows), + GraphicVideoCount = list.Count(x => x.IsMergeVideo == 1), VideoSizeTotal = ByteToGbConverter.ConvertBytesToGb(list.Sum(x => x.FileSize)), - VideoFavoriteSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.Favorite).Sum(x => x.FileSize)), - VideoCollectSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.Collect).Sum(x => x.FileSize)), - VideoFollowSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.UperPost).Sum(x => x.FileSize)), - GraphicVideoSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.ImageVideo).Sum(x => x.FileSize)), + VideoFavoriteSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_favorite).Sum(x => x.FileSize)), + VideoCollectSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_collects).Sum(x => x.FileSize)), + VideoFollowSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.dy_follows).Sum(x => x.FileSize)), + GraphicVideoSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.IsMergeVideo == 1).Sum(x => x.FileSize)), //TotalDiskSize= ByteToGbConverter.GetHostTotalDiskSpaceGB(), }; if (data.GraphicVideoSize == "0.00") { - if (list.Where(x => x.ViedoType == VideoTypeEnum.ImageVideo).Sum(x => x.FileSize) > 0) + if (list.Where(x => x.IsMergeVideo == 1).Sum(x => x.FileSize) > 0) { data.GraphicVideoSize = "<0.01";//避免显示0.00误导用户 } @@ -166,6 +171,8 @@ namespace dy.net.service return await _dyCollectVideoRepository.GetByIdAsync(id); } + + /// /// 重新下载选中的视频 /// @@ -194,7 +201,7 @@ namespace dy.net.service } // 3. 构建重新下载记录(提前准备数据,避免事务内耗时操作) - var reDownList = new List(); + var reDownList = new List(); var filePathsToDelete = new List(); // 收集待删除文件路径,统一处理 foreach (var video in videos) @@ -207,7 +214,7 @@ namespace dy.net.service } // 构建重新下载记录 - reDownList.Add(new ViedoReDown + reDownList.Add(new DouyinReDownload { Id = IdGener.GetLong().ToString(), CreateTime = DateTime.UtcNow, // 统一使用UTC时间,避免时区问题 @@ -233,7 +240,7 @@ namespace dy.net.service var transactionResult = await _dyCollectVideoRepository.UseTranAsync(async () => { // 4.1 批量插入重新下载记录(SqlSugar批量插入效率更高) - _dyCollectVideoRepository.InsertReDowns(reDownList); + _dyCollectVideoRepository.InsertReDowns(reDownList); // 4.2 批量删除原视频记录(使用视频实际存在的ID,避免无效删除) var actualDeleteIds = videos.Select(v => v.Id).ToList(); var deleteCount = await _dyCollectVideoRepository.DeleteByIdsAsync(actualDeleteIds); // 建议仓储层提供异步删除方法 @@ -242,75 +249,49 @@ namespace dy.net.service Serilog.Log.Error(e, "数据库事务执行失败:Ids={0}", string.Join(",", videoIds)); }); - // 5. 文件夹删除(非事务操作,失败不回滚数据库,可根据业务调整) - // 关键:先通过文件路径获取父文件夹,再删除整个文件夹(含子内容) - foreach (var filePath in filePathsToDelete) + // 5. 文件删除(非事务操作,失败不回滚数据库,可根据业务调整) + // 采用异步文件操作,避免同步IO阻塞线程(需.NET 5+支持) + foreach (var path in filePathsToDelete) { try { - // 1. 验证文件路径有效性 - if (string.IsNullOrWhiteSpace(filePath)) + if (File.Exists(path)) { - Serilog.Log.Error("文件路径为空,跳过文件夹删除"); - continue; - } - - // 2. 获取文件对应的父文件夹路径(无论文件是否存在,只要路径合法就能拿到父目录) - string parentDirPath = Path.GetDirectoryName(filePath); - if (string.IsNullOrWhiteSpace(parentDirPath)) - { - Serilog.Log.Error("无法获取父文件夹路径,文件路径无效:Filepath={0}", filePath); - continue; - } - - // 3. 验证父文件夹是否存在 - if (Directory.Exists(parentDirPath)) - { - Directory.Delete(parentDirPath, recursive: true); - Serilog.Log.Debug("文件夹删除成功(含子内容):ParentDir={0},关联文件路径={1}", parentDirPath, filePath); + File.Delete(path); // 异步删除,提升并发性能 + Serilog.Log.Debug("视频文件删除成功:Path={0}", path); } else { - Serilog.Log.Error("父文件夹不存在,跳过删除:ParentDir={0},关联文件路径={1}", parentDirPath, filePath); + Serilog.Log.Error("视频文件不存在,跳过删除:Path={0}", path); } } catch (IOException ex) { - Serilog.Log.Error(ex, "文件夹删除失败(IO异常):Filepath={0},父文件夹路径={1}", filePath, Path.GetDirectoryName(filePath)); - } - catch (UnauthorizedAccessException ex) - { - // 单独捕获权限异常,更精准的日志提示 - Serilog.Log.Error(ex, "文件夹删除失败(权限不足):Filepath={0},父文件夹路径={1}", filePath, Path.GetDirectoryName(filePath)); - } - catch (Exception ex) - { - // 捕获所有其他异常,避免循环中断 - Serilog.Log.Error(ex, "文件夹删除失败(未知异常):Filepath={0},父文件夹路径={1}", filePath, Path.GetDirectoryName(filePath)); + Serilog.Log.Error(ex, "视频文件删除失败:Path={0}", path); } } var CookieIds = reDownList.Select(x => x.CookieId).Distinct(); foreach (var ck in CookieIds) { - var cookie= douyinCookieRepository.GetById(ck); + var cookie = douyinCookieRepository.GetById(ck); if (cookie == null) continue; var viedoTypes = videos.Where(x => x.CookieId == ck).Select(x => x.ViedoType).Distinct(); - if(viedoTypes!=null&& viedoTypes.Any()) + if (viedoTypes != null && viedoTypes.Any()) { foreach (VideoTypeEnum item in viedoTypes) { switch (item) { - case VideoTypeEnum.Favorite: + case VideoTypeEnum.dy_favorite: cookie.FavHasSyncd = 0; break; - case VideoTypeEnum.Collect: + case VideoTypeEnum.dy_collects: cookie.CollHasSyncd = 0; break; - case VideoTypeEnum.UperPost: + case VideoTypeEnum.dy_follows: cookie.UperSyncd = 0; break; case VideoTypeEnum.ImageVideo: @@ -338,7 +319,7 @@ namespace dy.net.service /// 获取待重新下载的视频列表 /// /// - public async Task> GetViedoReDowns() + public async Task> GetViedoReDowns() { return await _dyCollectVideoRepository.GetViedoReDowns(); } diff --git a/utils/DouyinFileNameHelper.cs b/utils/DouyinFileNameHelper.cs index f96cc09..cf1e1ac 100644 --- a/utils/DouyinFileNameHelper.cs +++ b/utils/DouyinFileNameHelper.cs @@ -10,156 +10,45 @@ namespace dy.net.utils /// public static class DouyinFileNameHelper { - #region 配置参数 /// + /// 处理文件名/文件夹名,确保符合 Linux 限制(最大 255 字节 UTF-8 编码) /// - private const int MaxFileNameBytes = 60; - - - - /// - /// 非法字符替换后的占位符(也可设为空字符串) - /// - private const string IllegalCharReplacement = ""; - - - #endregion - - #region 处理抖音视频文件名 - /// - /// 生成抖音视频文件名 - /// - /// 抖音原始标题 - /// 排序号--如果有重复标题,根据时间排序取最后一个的序号+1 从001开始 - /// 安全可用的文件名 - public static string GenerateFileName(string originalTitle,string videoId) + /// 原始名称(支持英文、中文、混合字符) + /// 截取后为空时的默认名称(默认 "default") + /// 符合 Linux 规则的合法名称 + public static string SanitizeLinuxFileName(string originalName, string defaultName = "default") { - // 1. 基础容错(处理空值) - originalTitle = string.IsNullOrWhiteSpace(originalTitle) ? videoId : originalTitle; + // 1. 空值处理:直接返回默认名 + if (string.IsNullOrWhiteSpace(originalName)) + return defaultName.Replace(" ",""); - // 2. 净化标题:移除非法内容 - string purifiedTitle = PurifyTitle(originalTitle, videoId); - - // 3. 长度控制:按UTF-8字节数截断(避免超系统限制) - string truncatedTitle = TruncateByByteLength(purifiedTitle, MaxFileNameBytes); - - return truncatedTitle.Trim(); - } - #endregion - - #region 内部辅助方法:标题净化 - /// - /// 净化标题(移除非法字符、表情、话题标签) - /// - private static string PurifyTitle(string title,string id) - { - if (string.IsNullOrEmpty(title)) + // 2. 过滤 Linux 非法字符: + // - 禁止:/(路径分隔符)、\0(空字符) + // - 替换:其他特殊字符(如 :*?"<>|\\ )为下划线 _,避免创建失败 + var invalidChars = new[] { '/', '\0', ':', '*', '?', '"', '<', '>', '|', '\\' }; + string sanitizedName = originalName; + foreach (var c in invalidChars) { - title = id; + sanitizedName = sanitizedName.Replace(c, '_'); } + // 3. 计算 UTF-8 字节数,若未超 255 字节,直接返回 + byte[] utf8Bytes = Encoding.UTF8.GetBytes(sanitizedName); + if (utf8Bytes.Length <= 252) + return sanitizedName.Replace(" ", ""); ; - title = Regex.Replace(title, @"[^a-zA-Z0-9\u4e00-\u9fa5]", "-"); - // 步骤1:移除话题标签(#xxx 或 #xxx#yyy) - //title = Regex.Replace(title, @"#\S+", "", RegexOptions.Compiled); + // 4. 超过 255 字节,截取前 255 字节(避免破坏 UTF-8 字符) + byte[] truncatedBytes = new byte[252]; + Array.Copy(utf8Bytes, truncatedBytes, 252); - // 步骤2:移除表情符号(匹配常见表情Unicode区块) - //string emojiPattern = @"[\u1F600-\u1F64F\u1F300-\u1F5FF\u1F680-\u1F6FF\u1E000-\u1EFFF\u2600-\u2B55\u200D]"; - //title = Regex.Replace(title, emojiPattern, "", RegexOptions.Compiled); + // 5. 字节数组转回字符串(自动忽略不完整的尾部字节,避免乱码) + string truncatedName = Encoding.UTF8.GetString(truncatedBytes).TrimEnd('\0').Replace(" ",""); // 移除可能的空字符 - // 步骤3:过滤系统非法字符(Windows/macOS/Linux通用禁止) - char[] illegalChars = new[] { '/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0', '\t', '\n', '\r' }; - foreach (char c in illegalChars) - { - title = title.Replace(c.ToString(), IllegalCharReplacement); - } - char Separator = '-'; - - foreach (var c in Path.GetInvalidFileNameChars()) - { - title = title.Replace(c, '_'); - } - - - // 步骤5:合并连续分隔符(避免多个---) - title = Regex.Replace(title, $"{Separator}+", Separator.ToString(), RegexOptions.Compiled); - - // 步骤6:移除首尾无效字符(分隔符、点号) - title = title.Replace(" ","").Trim(Separator, '.'); - title = title.Replace("--", "-");//避免多个连续---- - // 容错:如果净化后为空,返回默认值 - return string.IsNullOrWhiteSpace(title) ? id : title; - } - #endregion - - #region 内部辅助方法:按字节数截断 - /// - /// 按UTF-8字节数截断字符串(避免截断半个中文) - /// - /// 要截断的字符串 - /// 最大字节数 - /// 截断后的字符串 - private static string TruncateByByteLength(string str, int maxBytes) - { - if (string.IsNullOrEmpty(str)) return str; - - byte[] bytes = Encoding.UTF8.GetBytes(str); - if (bytes.Length <= maxBytes) return str; - - // 从后往前截断,直到字节数≤maxBytes(避免半个中文) - for (int i = str.Length - 1; i >= 0; i--) - { - string truncated = str.Substring(0, i + 1); - if (Encoding.UTF8.GetByteCount(truncated) <= maxBytes) - { - return truncated; - } - } - - // 极端情况(单个字符就超字节数),返回空字符串 - return ""; - } - #endregion - - - - - // 清理路径中的特殊字符(避免创建文件夹失败) - public static string SanitizePath(string path) - { - try - { - if (string.IsNullOrWhiteSpace(path)) - { - path = "其他"; - } - - // 步骤1:移除话题标签(#xxx 或 #xxx#yyy) - //path = Regex.Replace(path, @"#\S+", "", RegexOptions.Compiled); - - // 步骤2:移除表情符号(匹配常见表情Unicode区块) - //string emojiPattern = @"[\u1F600-\u1F64F\u1F300-\u1F5FF\u1F680-\u1F6FF\u1E000-\u1EFFF\u2600-\u2B55\u200D]"; - //path = Regex.Replace(path, emojiPattern, "", RegexOptions.Compiled); - path = Regex.Replace(path, @"[^a-zA-Z0-9\u4e00-\u9fa5]", "-"); - path = path.Replace("--", "-");//避免重复--- - foreach (var c in Path.GetInvalidFileNameChars()) - { - path = path.Replace(c, '_'); - } - if (path.Length > 60) - { - path = path.Substring(0, 60); - } - return path.Trim().Replace(" ", ""); - } - catch (Exception ex) - { - return path; - } + // 6. 极端情况:截取后为空(如全是非法字符替换后无有效内容),返回默认名 + return string.IsNullOrWhiteSpace(truncatedName) ? defaultName : truncatedName; } - /// /// 检查字符串是否仅包含字母、数字、简体中文(无特殊字符) /// /// 待检查的字符串 @@ -184,4 +73,4 @@ namespace dy.net.utils return Regex.IsMatch(input, pattern, RegexOptions.None); } } -} +} \ No newline at end of file