1、删除视频,永不下载

2、去重-优先级
3、视频合成优化
4、其他优化
This commit is contained in:
jianzhichu
2025-12-03 23:43:10 +08:00
parent bd3a103f8f
commit 25e2a22dd2
29 changed files with 1023 additions and 702 deletions
+42 -4
View File
@@ -4,6 +4,7 @@ using dy.net.service;
using dy.net.utils; using dy.net.utils;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Drawing.Printing; using System.Drawing.Printing;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -15,12 +16,12 @@ namespace dy.net.Controllers
public class VideoController : ControllerBase public class VideoController : ControllerBase
{ {
private readonly DouyinVideoService dyCollectVideoService; 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.dyCollectVideoService = dyCollectVideoService;
this.douyinQuartzJobService = douyinQuartzJobService; this.douyinCommonService = douyinCommonService;
} }
/// <summary> /// <summary>
/// 分页查询收藏视频 /// 分页查询收藏视频
@@ -175,6 +176,7 @@ namespace dy.net.Controllers
}; };
} }
/// <summary> /// <summary>
/// 重新下载 /// 重新下载
/// </summary> /// </summary>
@@ -193,7 +195,6 @@ namespace dy.net.Controllers
var result = await dyCollectVideoService.ReDownloadViedoAsync(dto); var result = await dyCollectVideoService.ReDownloadViedoAsync(dto);
if (result) if (result)
{ {
//douyinQuartzJobService.StartReDownJobOnceAsync();...无法实现。。逆向失败
return Ok(new { code = 0, data = true }); return Ok(new { code = 0, data = true });
} }
else else
@@ -202,5 +203,42 @@ namespace dy.net.Controllers
} }
} }
} }
/// <summary>
/// 删除视频-不再下载
/// </summary>
/// <param name="Id"></param>
/// <returns></returns>
[HttpGet("vdelete/{vid}")]
public async Task<IActionResult> 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<string> { 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 });
}
}
}
}
} }
} }
+2 -10
View File
@@ -1,15 +1,7 @@
using ClockSnowFlake;
using dy.net.dto;
using dy.net.extension; using dy.net.extension;
using dy.net.model;
using dy.net.service; using dy.net.service;
using dy.net.utils; using dy.net.utils;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.FileProviders;
using Microsoft.IdentityModel.Tokens;
using Serilog; using Serilog;
using SqlSugar;
using System.Reflection;
using System.Text; using System.Text;
namespace dy.net namespace dy.net
@@ -204,8 +196,8 @@ ____/ _|_)_____/ _| _| \_|\____|
commonService.UpdateCollectViedoType(); commonService.UpdateCollectViedoType();
// 重置博主作品同步状态为未同步 // 重置博主作品同步状态为未同步
commonService.UpdateAllCookieSyncedToZero(); commonService.UpdateAllCookieSyncedToZero();
if(!isDevelopment) if (!isDevelopment)
{ {
// 启动定时任务 // 启动定时任务
var quartzJobService = services.GetRequiredService<DouyinQuartzJobService>(); var quartzJobService = services.GetRequiredService<DouyinQuartzJobService>();
@@ -5,7 +5,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl> <_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl>
<History>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||;</History> <History>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||;</History>
<LastFailureDetails /> <LastFailureDetails />
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+9 -5
View File
@@ -93,8 +93,8 @@ Cookie 及 `sec_user_id` 是同步功能的核心,需严格按步骤获取,
| 镜像标签 | 架构 | | 镜像标签 | 架构 |
| ----------------- | -------------- | | ----------------- | -------------- |
| `beta_1.7.1` | x86_64 (amd64) | | `beta_1.7.3` | x86_64 (amd64) |
| `arm_1.7.1` | ARM64 | | `arm_1.7.3` | ARM64 |
### 最新镜像查看 ### 最新镜像查看
[镜像列表](http://nas.synology2023.online:10108/api/docker/dysync/1) [镜像列表](http://nas.synology2023.online:10108/api/docker/dysync/1)
@@ -112,7 +112,7 @@ docker run -d --restart=always \
-v /opt/dysync/uper:/app/uper \ -v /opt/dysync/uper:/app/uper \
-p 10103:10101 \ -p 10103:10101 \
--name dysync2025 \ --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 指定 # 注意:-p 后面的容器端口,可以用环境变量类似:ASPNETCORE_URLS = http://+:10108 指定
``` ```
@@ -126,7 +126,7 @@ version: '3.8'
services: services:
dysync: 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 # 容器名称 container_name: dysync2025 # 容器名称
restart: unless-stopped # 始终重启容器,除非容器被手动停止或Docker服务停止 restart: unless-stopped # 始终重启容器,除非容器被手动停止或Docker服务停止
ports: ports:
@@ -167,6 +167,8 @@ services:
![输入图片说明](docs/set.png) ![输入图片说明](docs/set.png)
![输入图片说明](docs/appconf.png)
![输入图片说明](docs/player.png) ![输入图片说明](docs/player.png)
![输入图片说明](docs/follows.png) ![输入图片说明](docs/follows.png)
@@ -189,7 +191,7 @@ services:
8. ✅ Cookie 过期提醒,在D音授权页可查看 8. ✅ Cookie 过期提醒,在D音授权页可查看
9.重复视频去重(一个视频同时属于收藏视频、喜欢的视频或指定的博主作品 9.自动根据去重规则进行去重(可设置去重优先级,同一个视频出现再多个分类时适用
10. ✅ 在同步记录页面中直接播放视频 10. ✅ 在同步记录页面中直接播放视频
@@ -199,4 +201,6 @@ services:
12. ✅ 关注列表支持新增,非关注的博主 12. ✅ 关注列表支持新增,非关注的博主
13. ✅ 增加永久删除功能,删除后,以后不会再同步该视频
+28 -23
View File
@@ -13,11 +13,11 @@
</div> </div>
</a-form-item> </a-form-item>
<a-form-item has-feedback label="每次同步(条数)" name="BatchCount" :wrapper-col="{ span:6}" style="margin-left:30px"> <a-form-item has-feedback label="每次同步上限" name="BatchCount" :wrapper-col="{ span:10}" style="margin-left:30px">
<a-input-number v-model:value="formState.BatchCount" placeholder="请输入查询条数(最大30)" :min="10" :max="30" /> <a-input-number v-model:value="formState.BatchCount" placeholder="请输入每次下载数量上限(最大30)" :min="10" :max="30" />
<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" />
<span>每次同步获取的条数范围10-30建议使用默认值</span> <span>每次同步获取的条数范围10-30建议使用默认值18,抖音默认的分页大小</span>
</div> </div>
</a-form-item> </a-form-item>
</div> </div>
@@ -121,24 +121,7 @@
<div class="form-section"> <div class="form-section">
<h3 class="section-title">其他配置</h3> <h3 class="section-title">其他配置</h3>
<a-form-item has-feedback label="日志保留(天数)" name="LogKeepDay" :wrapper-col="{ span: 6 }" style="margin-left:30px"> <a-form-item v-show="formState.AutoDistinct" has-feedback label="去重优先等级" name="PriorityLevel" :wrapper-col="{ span: 8 }">
<a-input-number v-model:value="formState.LogKeepDay" placeholder="请输入保留天数" :min="1" :max="90" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>系统运行日志的保留天数范围1-90过期自动清理</span>
</div>
</a-form-item>
<a-form-item has-feedback label="是否自动去重" name="AutoDistinct" :wrapper-col="{ span: 10 }">
<a-switch v-model:checked="formState.AutoDistinct" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>
启用后同一个视频,只会下载一次(但是暂时不能决定保留哪个文件夹的)
</span>
</div>
</a-form-item>
<a-form-item v-show="formState.AutoDistinct" has-feedback label="优先级" name="PriorityLevel" :wrapper-col="{ span: 10 }">
<!-- Tag 拖拽容器 --> <!-- Tag 拖拽容器 -->
<div class="tag-drag-container"> <div class="tag-drag-container">
<!-- Tag 拖拽容器绑定 ref Sortable 初始化 --> <!-- Tag 拖拽容器绑定 ref Sortable 初始化 -->
@@ -150,8 +133,30 @@
</a-tag> </a-tag>
</div> </div>
</div> </div>
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>同一个视频同时存在在多个类型的视频分类时按照优先级保优先级最高的一个
鼠标放到点击鼠标左键即可拖拽调整优先级</span>
</div>
</a-form-item> </a-form-item>
<a-form-item has-feedback label="日志保留(天数)" name="LogKeepDay" :wrapper-col="{ span: 6 }" style="margin-left:30px">
<a-input-number v-model:value="formState.LogKeepDay" placeholder="请输入保留天数" :min="1" :max="90" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>系统运行日志的保留天数范围1-90过期自动清理</span>
</div>
</a-form-item>
<!-- <a-form-item has-feedback label="是否自动去重" name="AutoDistinct" :wrapper-col="{ span: 10 }">
<a-switch v-model:checked="formState.AutoDistinct" disabled />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>
启用后同一个视频,只会下载一次(但是暂时不能决定保留哪个文件夹的)
</span>
</div>
</a-form-item> -->
</div> </div>
<!-- 操作按钮 --> <!-- 操作按钮 -->
@@ -197,7 +202,7 @@ const template_options = [
// 状态定义 // 状态定义
const componentDisabled = ref(true); const componentDisabled = ref(true);
const downImgVideo = ref(false); const downImgVideo = ref(true);
// 表单数据结构(新增 FullFollowedTitleTemplate 字段) // 表单数据结构(新增 FullFollowedTitleTemplate 字段)
interface FormState { interface FormState {
@@ -315,7 +320,7 @@ const getConfig = () => {
PriorityLevel: res.data.priorityLevel, PriorityLevel: res.data.priorityLevel,
}); });
downImgVideo.value = res.data.downImageVideoFromEnv; // downImgVideo.value = res.data.downImageVideoFromEnv;
tagData.value = JSON.parse(res.data.priorityLevel); tagData.value = JSON.parse(res.data.priorityLevel);
} else { } else {
message.error(res.erro || '获取配置失败', 8); message.error(res.erro || '获取配置失败', 8);
+65 -23
View File
@@ -125,6 +125,10 @@
<ShareAltOutlined /> <ShareAltOutlined />
分享 分享
</a-button> </a-button>
<a-button type="link" danger @click="handleDelete(record)" :disabled="!record.id">
<DeleteOutlined />
删除
</a-button>
</a-space> </a-space>
</template> </template>
</template> </template>
@@ -225,7 +229,7 @@ const columns = ref([
title: '同步类型', title: '同步类型',
dataIndex: 'viedoTypeStr', dataIndex: 'viedoTypeStr',
align: 'center', align: 'center',
width: 150, width: 120,
}, },
{ {
title: '博主', title: '博主',
@@ -236,7 +240,7 @@ const columns = ref([
{ {
title: '视频类型', title: '视频类型',
dataIndex: 'viedoCate', dataIndex: 'viedoCate',
width: 300, width: 200,
align: 'center', align: 'center',
}, },
{ {
@@ -276,7 +280,7 @@ watch(isBatchMode, (isOpen) => {
// 基础状态(优化:删除冗余的 datas 响应式数组) // 基础状态(优化:删除冗余的 datas 响应式数组)
const loading = ref(false); const loading = ref(false);
const showImageViedo = ref(false); const showImageViedo = ref(true);
const dataSource = ref<DataItem[]>([]); // 直接用 ref 数组存储表格数据,减少响应式嵌套 const dataSource = ref<DataItem[]>([]); // 直接用 ref 数组存储表格数据,减少响应式嵌套
// 查询参数 // 查询参数
@@ -306,7 +310,16 @@ const pagination = ref({
current: 1, current: 1,
defaultPageSize: 10, defaultPageSize: 10,
total: 0, total: 0,
showSizeChanger: true, // 强制显示「每页显示数量」下拉框(关键修复)
showTotal: () => `${0}`, 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 = () => { // const getConfig = () => {
useApiStore() // useApiStore()
.apiGetConfig() // .apiGetConfig()
.then((res) => { // .then((res) => {
if (res.code === 0) { // if (res.code === 0) {
showImageViedo.value = res.data.downImageVideoFromEnv; // showImageViedo.value = res.data.downImageVideoFromEnv;
} else { // } else {
message.warning(`获取配置失败: ${res.message}`); // message.warning(`获取配置失败: ${res.message}`);
} // }
GetRecords(); // GetRecords();
}) // })
.catch((error) => { // .catch((error) => {
console.error('获取配置失败:', error); // console.error('获取配置失败:', error);
message.error('获取配置失败,请稍后重试'); // message.error('获取配置失败,请稍后重试');
}); // });
}; // };
/** 视频类型切换事件 */ /** 视频类型切换事件 */
const onViedoTypeChanged = () => { const onViedoTypeChanged = () => {
@@ -613,9 +626,9 @@ const handleBatchDelete = () => {
} }
Modal.confirm({ Modal.confirm({
title: '确认删除', title: '确认重新下载吗',
content: `您确定要删除选中的 ${selectedRowKeys.value.length} 条视频数据吗?此操作不可撤销!`, content: `您确定要重新下载选中的 ${selectedRowKeys.value.length} 条视频数据吗?`,
okText: '确认删除', okText: '确认重新下载',
cancelText: '取消', cancelText: '取消',
okType: 'danger', okType: 'danger',
onOk: async () => { 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) => { const copyVideoPath = (path?: string) => {
if (!path) { if (!path) {
@@ -764,7 +805,8 @@ const copyVideoPath = (path?: string) => {
// -------------------------- 页面初始化 -------------------------- // -------------------------- 页面初始化 --------------------------
onMounted(() => { onMounted(() => {
getConfig(); // getConfig();
GetRecords();
}); });
</script> </script>
+10 -1
View File
@@ -210,6 +210,14 @@ export const useApiStore = defineStore('coreapi', () => {
}); });
} }
//删除
async function DeleteVideo(param: string) {
return http.request<any, Response<any>>('/api/video/vdelete/' + param, 'get').then(r => {
return r.data;
}).finally(() => {
});
}
//检查版本 //检查版本
@@ -260,6 +268,7 @@ export const useApiStore = defineStore('coreapi', () => {
SyncFollow, SyncFollow,
OpenOrCloseSync, OpenOrCloseSync,
OpenOrCloseFullSync, OpenOrCloseFullSync,
ReDownViedos ReDownViedos,
DeleteVideo
}; };
}); });
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"dbconn": "", "dbconn": "",
"dbtype": "Sqlite", "dbtype": "Sqlite",
"tagName": "dev_1.0" "tagName": "beta_1.7.3"
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+41 -24
View File
@@ -3,34 +3,51 @@
/// <summary> /// <summary>
/// 任务配置实体 /// 任务配置实体
/// </summary> /// </summary>
//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));
// }
// /// <summary>
// /// 任务类型
// /// </summary>
// public Type JobType { get; }
// /// <summary>
// /// 任务Key
// /// </summary>
// public string JobKey { get; }
// /// <summary>
// /// 触发器Key
// /// </summary>
// public string TriggerKey { get; }
// /// <summary>
// /// 任务描述
// /// </summary>
// public string Description { get; }
//}
public class JobConfig 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) public JobConfig(Type jobType, string jobKey, string triggerKey, string description)
{ {
JobType = jobType ?? throw new ArgumentNullException(nameof(jobType)); JobType = jobType ?? throw new ArgumentNullException(nameof(jobType), "任务类型不能为空");
JobKey = jobKey ?? throw new ArgumentNullException(nameof(jobKey)); JobKey = jobKey ?? throw new ArgumentNullException(nameof(jobKey), "任务Key不能为空");
TriggerKey = triggerKey ?? throw new ArgumentNullException(nameof(triggerKey)); TriggerKey = triggerKey ?? throw new ArgumentNullException(nameof(triggerKey), "触发器Key不能为空");
Description = description ?? throw new ArgumentNullException(nameof(description)); Description = description ?? throw new ArgumentNullException(nameof(description), "任务描述不能为空");
} }
/// <summary>
/// 任务类型
/// </summary>
public Type JobType { get; }
/// <summary>
/// 任务Key
/// </summary>
public string JobKey { get; }
/// <summary>
/// 触发器Key
/// </summary>
public string TriggerKey { get; }
/// <summary>
/// 任务描述
/// </summary>
public string Description { get; }
} }
} }
+15 -13
View File
@@ -4,24 +4,26 @@ namespace dy.net.dto
{ {
public enum VideoTypeEnum public enum VideoTypeEnum
{ {
/// <summary>
///喜欢的
/// </summary>
[Description("喜欢的")] [Description("喜欢的")]
Favorite = 1, dy_favorite = 1,
/// <summary>
///收藏的
/// </summary>
[Description("收藏的")] [Description("收藏的")]
Collect = 2, dy_collects = 2,
/// <summary>
/// 关注的
/// </summary>
[Description("关注的")] [Description("关注的")]
UperPost = 3, dy_follows = 3,
/// <summary>
/// 图文视频,无用了,但是不能删...
/// </summary>
[Description("图片视频")] [Description("图片视频")]
ImageVideo = 4 ImageVideo = 4
} }
public enum QuartzJobTypeEnum
{
[Description("[Favorite]")]
Favorite = 1,
[Description("[Collect]")]
Collect = 2,
[Description("[Followed]")]
Followed = 3
}
} }
+109
View File
@@ -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
{ /// <summary>
/// 抖音任务依赖监听器(独立公共类)
/// 作用:监听任务执行完成事件,触发下一个依赖任务,实现顺序执行
/// </summary>
public class DouyinJobDependencyListener : IJobListener
{
// 监听器名称(唯一标识,不可重复)
public string Name => "DouyinJobDependencyListener";
/// <summary>
/// 任务配置字典(从外部注入)
/// </summary>
private readonly Dictionary<string, JobConfig> _jobConfigs;
/// <summary>
/// 任务依赖关系(从外部注入,定义执行顺序)
/// </summary>
private readonly Dictionary<string, string> _jobDependency;
/// <summary>
/// 任务服务(用于触发下一个任务,从外部注入)
/// </summary>
private readonly DouyinQuartzJobService _jobService;
/// <summary>
/// 构造函数(依赖注入)
/// </summary>
/// <param name="jobConfigs">任务配置</param>
/// <param name="jobDependency">任务依赖关系</param>
/// <param name="jobService">任务服务</param>
public DouyinJobDependencyListener(
Dictionary<string, JobConfig> jobConfigs,
Dictionary<string, string> jobDependency,
DouyinQuartzJobService jobService)
{
_jobConfigs = jobConfigs ?? throw new ArgumentNullException(nameof(jobConfigs), "任务配置不能为空");
_jobDependency = jobDependency ?? throw new ArgumentNullException(nameof(jobDependency), "任务依赖关系不能为空");
_jobService = jobService ?? throw new ArgumentNullException(nameof(jobService), "任务服务不能为空");
}
/// <summary>
/// 任务执行前触发(无需处理)
/// </summary>
public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
/// <summary>
/// 任务被否决执行时触发(无需处理)
/// </summary>
public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
/// <summary>
/// 任务执行完成后触发(核心逻辑:触发下一个依赖任务)
/// </summary>
public 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);
}
}
}
}
+8 -12
View File
@@ -15,9 +15,8 @@ namespace dy.net.job
DouyinCommonService douyinCommonService,DouyinFollowService douyinFollowService,DouyinMergeVideoService douyinMergeVideoService) DouyinCommonService douyinCommonService,DouyinFollowService douyinFollowService,DouyinMergeVideoService douyinMergeVideoService)
: base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService,douyinFollowService, douyinMergeVideoService) { } : base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService,douyinFollowService, douyinMergeVideoService) { }
protected override string JobType => SystemStaticUtil.DY_COLLECTS;
protected override VideoTypeEnum VideoType => VideoTypeEnum.dy_collects;
protected override VideoTypeEnum VideoType => VideoTypeEnum.Collect;
protected override async Task BeforeProcessCookies() protected override async Task BeforeProcessCookies()
{ {
@@ -61,34 +60,31 @@ namespace dy.net.job
return Path.Combine(cookie.SavePath, "author"); 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) if (syncCount > 0)
{ {
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频"); Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频");
cookie.CollHasSyncd = 1; cookie.CollHasSyncd = 1;
await douyinCookieService.UpdateAsync(cookie); await douyinCookieService.UpdateAsync(cookie);
} }
else else
{ {
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次没有查询到新的视频"); Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],没有可以同步的新视频");
} }
} }
protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item) protected override VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item)
{ {
return new VideoEntityDifferences return new VideoEntityDifferences();
{
VideoType = VideoTypeEnum.Collect,
};
} }
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed) protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed)
{ {
var (tag1, _, _) = GetVideoTags(item); var (tag1, _, _) = GetVideoTags(item);
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizePath(tag1); var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizeLinuxFileName(tag1);
var folder = Path.Combine(cookie.SavePath, safeTag1, $"{DouyinFileNameHelper.SanitizePath(item.Desc)}@{item.AwemeId}"); var folder = Path.Combine(cookie.SavePath, safeTag1, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}@{item.AwemeId}");
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder; return folder;
} }
+6 -15
View File
@@ -17,9 +17,8 @@ namespace dy.net.job
DouyinCommonService douyinCommonService,DouyinFollowService douyinFollowService,DouyinMergeVideoService douyinMergeVideoService) DouyinCommonService douyinCommonService,DouyinFollowService douyinFollowService,DouyinMergeVideoService douyinMergeVideoService)
: base(douyinCookieService, douyinHttpClientService, douyinVideoService, douyinCommonService, douyinFollowService, 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<List<DouyinCookie>> GetValidCookies() protected override async Task<List<DouyinCookie>> GetValidCookies()
{ {
@@ -54,33 +53,25 @@ namespace dy.net.job
return Path.Combine(cookie.FavSavePath, "author"); 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) if (syncCount > 0)
{ {
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频"); Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频");
cookie.FavHasSyncd = 1; cookie.FavHasSyncd = 1;
await douyinCookieService.UpdateAsync(cookie); await douyinCookieService.UpdateAsync(cookie);
} }
else 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) protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed)
{ {
var (tag1, _, _) = GetVideoTags(item); var (tag1, _, _) = GetVideoTags(item);
var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizePath(tag1); var safeTag1 = string.IsNullOrWhiteSpace(tag1) ? "other" : DouyinFileNameHelper.SanitizeLinuxFileName(tag1);
var folder = Path.Combine(cookie.FavSavePath, safeTag1, $"{DouyinFileNameHelper.SanitizePath(item.Desc)}@{item.AwemeId}"); var folder = Path.Combine(cookie.FavSavePath, safeTag1, $"{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}@{item.AwemeId}");
if (!Directory.Exists(folder)) Directory.CreateDirectory(folder); if (!Directory.Exists(folder)) Directory.CreateDirectory(folder);
return folder; return folder;
} }
+113 -82
View File
@@ -82,7 +82,7 @@ namespace dy.net.job
/// 任务类型名称,用于日志记录和区分不同的同步任务 /// 任务类型名称,用于日志记录和区分不同的同步任务
/// 子类必须实现此属性并返回具体的任务类型 /// 子类必须实现此属性并返回具体的任务类型
/// </summary> /// </summary>
protected abstract string JobType { get; } //protected abstract string VideoType { get; }
protected abstract VideoTypeEnum VideoType { get; } protected abstract VideoTypeEnum VideoType { get; }
@@ -131,14 +131,13 @@ namespace dy.net.job
var config = douyinCommonService.GetConfig(); var config = douyinCommonService.GetConfig();
if (config == null) if (config == null)
{ {
Log.Debug($"{JobType}-未获取到系统配置,任务终止!!!"); Log.Debug($"{VideoType}-未获取到系统配置,任务终止!!!");
return; return;
} }
// 2. 从配置中获取每页请求数量--固定18
// 2. 从配置中获取每页请求数量 //if (config.BatchCount > 0)
if (config.BatchCount > 0) // count = config.BatchCount.ToString();
count = config.BatchCount.ToString();
// 3. 在处理Cookie之前执行的预处理操作 // 3. 在处理Cookie之前执行的预处理操作
await BeforeProcessCookies(); await BeforeProcessCookies();
@@ -147,11 +146,11 @@ namespace dy.net.job
var cookies = await GetValidCookies(); var cookies = await GetValidCookies();
if (cookies == null || !cookies.Any()) if (cookies == null || !cookies.Any())
{ {
Log.Debug($"{JobType}-无有效Cookie,任务终止!!!"); Log.Debug($"{VideoType}-无有效Cookie,任务终止!!!");
return; return;
} }
Log.Debug($"{JobType}-共发现{cookies.Count}个Cookie,同步任务即将开始..."); Log.Debug($"{VideoType}-共发现{cookies.Count}个Cookie,同步任务即将开始...");
// 6. 遍历每个有效的Cookie,执行同步操作 // 6. 遍历每个有效的Cookie,执行同步操作
foreach (var cookie in cookies) foreach (var cookie in cookies)
@@ -262,8 +261,9 @@ namespace dy.net.job
/// </summary> /// </summary>
/// <param name="cookie">用户Cookie</param> /// <param name="cookie">用户Cookie</param>
/// <param name="syncCount">本次同步成功的视频数量</param> /// <param name="syncCount">本次同步成功的视频数量</param>
/// <param name="followed"></param>
/// <returns>一个表示异步操作的任务</returns> /// <returns>一个表示异步操作的任务</returns>
protected abstract Task HandleSyncCompletion(DouyinCookie cookie, int syncCount); protected abstract Task HandleSyncCompletion(DouyinCookie cookie, int syncCount,DouyinFollowed followed=null);
/// <summary> /// <summary>
/// 获取视频实体的差异信息 /// 获取视频实体的差异信息
@@ -272,7 +272,10 @@ namespace dy.net.job
/// <param name="cookie">用户Cookie</param> /// <param name="cookie">用户Cookie</param>
/// <param name="item">视频信息</param> /// <param name="item">视频信息</param>
/// <returns>视频实体的差异信息,包含视频类型和简化标题</returns> /// <returns>视频实体的差异信息,包含视频类型和简化标题</returns>
protected abstract VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item); protected virtual VideoEntityDifferences GetVideoEntityDifferences(DouyinCookie cookie, Aweme item)
{
return new VideoEntityDifferences();
}
/// <summary> /// <summary>
/// 获取NFO文件中的图片(如海报)文件名 /// 获取NFO文件中的图片(如海报)文件名
@@ -302,13 +305,13 @@ namespace dy.net.job
// 检查Cookie是否有效 // 检查Cookie是否有效
if (!IsCookieValid(cookie)) if (!IsCookieValid(cookie))
{ {
Log.Debug($"{JobType}-Cookie[{cookie.UserName}]无效,任务终止!!!"); Log.Debug($"{VideoType}-Cookie[{cookie.UserName}]无效,任务终止!!!");
return; return;
} }
Log.Debug($"{JobType}- Cookie-[{cookie.UserName}]开始同步..."); Log.Debug($"{VideoType}- Cookie-[{cookie.UserName}]开始同步...");
//up主上传视频特殊处理 //up主上传视频特殊处理
if (JobType == SystemStaticUtil.DY_FOLLOWEDS) if (VideoType ==VideoTypeEnum.dy_follows)
{ {
int syncCount = 0; // 本次同步成功的视频数量 int syncCount = 0; // 本次同步成功的视频数量
string cursor = "0"; // 初始游标 string cursor = "0"; // 初始游标
@@ -324,18 +327,18 @@ namespace dy.net.job
} }
if (string.IsNullOrWhiteSpace(firstUp.SecUid)) if (string.IsNullOrWhiteSpace(firstUp.SecUid))
{ {
Log.Debug($"{JobType}-Cookie[{firstUp.UperName}]无效,没有sec_userid,任务终止!!!"); Log.Debug($"{VideoType}-Cookie[{firstUp.UperName}]无效,没有sec_userid,任务终止!!!");
return; return;
} }
foreach (var item in follows) foreach (var followed in follows)
{ {
cursor = "0"; // 初始游标 cursor = "0"; // 初始游标
await GetViedos(cookie, config, syncCount, cursor, hasMore, item); await GetViedos(cookie, config, syncCount, cursor, hasMore, followed);
hasMore = true; hasMore = true;
// 处理同步完成后的操作 // 处理同步完成后的操作
await HandleSyncCompletion(cookie, syncCount); await HandleSyncCompletion(cookie, syncCount,followed);
} }
} }
else else
@@ -353,7 +356,7 @@ namespace dy.net.job
} }
catch (Exception ex) 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); var data = await FetchVideoData(cookie, cursor, followed == null ? "" : followed?.SecUid);
if (data == null) if (data == null)
{ {
Log.Debug($"{JobType}-Cookie[{cookie.UserName}]读取数据失败!!!"); Log.Debug($"{VideoType}-Cookie[{cookie.UserName}]读取数据失败!!!");
break; break;
} }
@@ -387,7 +390,7 @@ namespace dy.net.job
//当syncCount达到上限时,跳出循环 //当syncCount达到上限时,跳出循环
if (config.BatchCount > 0 && syncCount >= config.BatchCount) if (config.BatchCount > 0 && syncCount >= config.BatchCount)
{ {
Log.Debug($"{JobType}-Cookie[{cookie.UserName}]本次同步达到上限{config.BatchCount},停止同步!!!"); Log.Debug($"{VideoType}-Cookie[{cookie.UserName}]本次同步达到上限{config.BatchCount},停止同步!!!");
break; break;
} }
@@ -412,12 +415,28 @@ namespace dy.net.job
var videos = new List<DouyinVideo>(); var videos = new List<DouyinVideo>();
foreach (var item in data.AwemeList) 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) if (!Goon)
{ {
continue; continue;
} }
if (exitVideo!=null) {
await douyinVideoService.DeleteById(exitVideo.Id);
}
var uper = await douyinFollowService.GetByUperId(item.AuthorUserId.ToString(), cookie.MyUserId); var uper = await douyinFollowService.GetByUperId(item.AuthorUserId.ToString(), cookie.MyUserId);
if (uper != null) if (uper != null)
{ {
@@ -442,13 +461,11 @@ namespace dy.net.job
return videos; return videos;
} }
private async Task<bool> AutoDistinct(AppConfig config, Aweme item) private async Task<bool> AutoDistinct(AppConfig config, Aweme item,DouyinCookie cookie,DouyinVideo exitVideo)
{ {
// 去重,检查视频是否已存在(按优先级下载) // 去重,检查视频是否已存在(按优先级下载)
if (config.AutoDistinct) if (config.AutoDistinct)
{ {
// 1. 查询数据库中是否已存在该视频(通过 AwemeId 唯一标识)
var exitVideo = await douyinVideoService.GetByAwemeId(item.AwemeId);
if (exitVideo != null) if (exitVideo != null)
{ {
// 2. 已存在视频:先判断本地文件是否存在 // 2. 已存在视频:先判断本地文件是否存在
@@ -465,7 +482,7 @@ namespace dy.net.job
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error($"解析优先级配置失败:{ex.Message}", ex); Log.Error($"{VideoType}-解析优先级配置失败:{ex.Message}", ex);
priLevs = new List<PriorityLevelDto>(); // 解析失败则用默认优先级 priLevs = new List<PriorityLevelDto>(); // 解析失败则用默认优先级
} }
} }
@@ -497,23 +514,24 @@ namespace dy.net.job
if (exitVideoType == currentVideoType) if (exitVideoType == currentVideoType)
{ {
// 已存在同优先级视频 → 跳过下载(避免重复) // 已存在同优先级视频 → 跳过下载(避免重复)
Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(同最高优先级),跳过"); //Log.Debug($"{VideoType}-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(同最高优先级),跳过");
return false; return false;
} }
else else
{ {
// 已存在「低优先级」视频 → 替换(删除旧文件,继续下载新的最高优先级视频) // 已存在「低优先级」视频 → 替换(删除旧文件,继续下载新的最高优先级视频)
Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(低优先级:{exitVideoType}),替换为最高优先级:{currentVideoType}"); //Log.Debug($"{VideoType}-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在(低优先级:{exitVideoType}),替换为最高优先级:{currentVideoType}");
// 删除旧的低优先级文件(可选:也可保留备份,根据需求调整) // 删除旧的低优先级文件(可选:也可保留备份,根据需求调整)
try try
{ {
File.Delete(exitVideo.VideoSavePath); //File.Delete(exitVideo.VideoSavePath);
Log.Debug($"已删除旧文件:{exitVideo.VideoSavePath}"); DeleteOldViedo(config, exitVideo);
//Log.Debug($"已删除旧文件:{exitVideo.VideoSavePath}");
} }
catch (Exception ex) 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) if (exitVideoType == maxPriorityType)
{ {
// 已存在「最高优先级」视频 → 跳过(不替换最高优先级) // 已存在「最高优先级」视频 → 跳过(不替换最高优先级)
Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在最高优先级视频({maxPriorityType}),当前类型({currentVideoType})优先级低,跳过"); //Log.Debug($"{VideoType}-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在最高优先级视频({maxPriorityType}),当前类型({currentVideoType})优先级低,跳过");
return false; return false;
} }
else else
@@ -539,39 +557,70 @@ namespace dy.net.job
if (currentSort < exitSort) 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)) DeleteOldViedo(config, exitVideo);
{
File.Delete(exitVideo.VideoSavePath);
}
// 继续下载 // 继续下载
} }
else else
{ {
// 当前类型优先级更低或相等 → 跳过 // 当前类型优先级更低或相等 → 跳过
Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在更高/同等优先级视频({exitVideoType}),当前类型({currentVideoType})跳过"); //Log.Debug($"{VideoType}-视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]已存在更高/同等优先级视频({exitVideoType}),当前类型({currentVideoType})跳过");
return false; 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))
{ {
// 数据库存在记录,但本地文件丢失 → 直接重新下载(无论优先级) File.Delete(nfoPath);
Log.Debug($"视频-{exitVideo.AwemeId}-[{exitVideo.VideoTitle}]数据库存在但本地文件丢失,重新下载"); //删除同名的封面文件
//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 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;
} }
/// <summary> /// <summary>
@@ -609,22 +658,22 @@ namespace dy.net.job
// 如果文件已存在,跳过 // 如果文件已存在,跳过
if (File.Exists(savePath)) if (File.Exists(savePath))
{ {
//Log.Debug($"{JobType}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]已存在,跳过下载."); //Log.Debug($"{VideoType}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]已存在,跳过下载.");
return null; return null;
} }
Log.Debug($"{JobType}-视频[{DouyinFileNameHelper.SanitizePath(item.Desc)}]开始下载..."); Log.Debug($"{VideoType}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}]开始下载...");
// 随机延迟,模拟人类操作 // 随机延迟,模拟人类操作
await Task.Delay(_random.Next(1, 4) * 1000); await Task.Delay(_random.Next(1, 4) * 1000);
// 下载视频 // 下载视频
if (!await douyinHttpClientService.DownloadAsync(videoUrl, savePath, cookie.Cookies)) 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; return null;
} }
else 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)) if (string.IsNullOrWhiteSpace(cookie.ImgSavePath))
{ {
Log.Error($"{JobType}-图文视频同步-没有配置图片存储路径,任务终止!!!"); Log.Error($"{VideoType}-图文视频同步-没有配置图片存储路径,任务终止!!!");
return null; 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 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); var mergeResult = await douyinMergeVideoService.MergeToVideo(cookie.Cookies, AppContext.BaseDirectory, reqParams, savePath, fileNamefolder, config.DownImageVideo, config.DownImage, config.DownMp3);
if (!mergeResult) if (!mergeResult)
{ {
Log.Error($"{JobType}-图文视频-[{DouyinFileNameHelper.SanitizePath(item.Desc)}]合成失败!!!"); Log.Error($"{VideoType}-图文视频-[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc)}]合成失败!!!");
return null; return null;
} }
@@ -722,13 +771,13 @@ namespace dy.net.job
// 检查合成后的视频文件是否有效 // 检查合成后的视频文件是否有效
if (!File.Exists(savePath) || new FileInfo(savePath).Length <= 0) 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)) if (Directory.Exists(fileNamefolder))
{ {
File.Delete(savePath); File.Delete(savePath);
Directory.Delete(fileNamefolder, true); Directory.Delete(fileNamefolder, true);
Log.Error($"{JobType}-图文视频-删除合成失败的视频文件和目录..."); Log.Error($"{VideoType}-图文视频-删除合成失败的视频文件和目录...");
} }
return null; return null;
} }
@@ -765,13 +814,14 @@ namespace dy.net.job
// 特殊处理合成视频的字段 // 特殊处理合成视频的字段
videoEntity.FileHash = string.Empty; // 合成视频没有原始文件哈希 videoEntity.FileHash = string.Empty; // 合成视频没有原始文件哈希
videoEntity.VideoUrl = "/"; // 合成视频没有原始URL videoEntity.VideoUrl = "/"; // 合成视频没有原始URL
videoEntity.ViedoType = VideoTypeEnum.ImageVideo; // 标记为图片合成视频 videoEntity.ViedoType = VideoType;
videoEntity.IsMergeVideo = 1;// 标记为图片合成视频
return videoEntity; return videoEntity;
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, $"{JobType}-图片视频同步-处理图片集并合成视频时出错"); Log.Error(ex, $"{VideoType}-图片视频同步-处理图片集并合成视频时出错");
return null; return null;
} }
} }
@@ -788,30 +838,11 @@ namespace dy.net.job
try try
{ {
await douyinVideoService.BatchInsertOrUpdate(videos); 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; return videos.Count;
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, $"{JobType}-批量保存视频到数据库失败"); Log.Error(ex, $"{VideoType}-批量保存视频到数据库失败");
// 清理保存失败的视频文件 // 清理保存失败的视频文件
await CleanupFailedVideos(videos); await CleanupFailedVideos(videos);
return 0; return 0;
@@ -938,7 +969,7 @@ namespace dy.net.job
/// <returns>一个表示异步操作的任务</returns> /// <returns>一个表示异步操作的任务</returns>
private async Task CleanupFailedVideos(List<DouyinVideo> videos) private async Task CleanupFailedVideos(List<DouyinVideo> videos)
{ {
Log.Debug($"{JobType}-数据库保存失败,开始清理本次下载的视频目录..."); Log.Debug($"{VideoType}-数据库保存失败,开始清理本次下载的视频目录...");
foreach (var video in videos) foreach (var video in videos)
{ {
@@ -956,12 +987,12 @@ namespace dy.net.job
{ {
Directory.Delete(directory); Directory.Delete(directory);
} }
Log.Debug($"{JobType}-清理失败视频文件成功: {video.VideoSavePath}!!!"); Log.Debug($"{VideoType}-清理失败视频文件成功: {video.VideoSavePath}!!!");
} }
catch (Exception ex) 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 return new DouyinVideo
{ {
ViedoType = diffs.VideoType, ViedoType = VideoType,
AwemeId = item.AwemeId, AwemeId = item.AwemeId,
Author = item.Author?.Nickname, Author = item.Author?.Nickname,
AuthorId = item.Author?.Uid, AuthorId = item.Author?.Uid,
@@ -1053,7 +1084,7 @@ namespace dy.net.job
/// <summary> /// <summary>
/// 视频类型 /// 视频类型
/// </summary> /// </summary>
public VideoTypeEnum VideoType { get; set; } //public VideoTypeEnum VideoType { get; set; }
/// <summary> /// <summary>
/// 简化的视频标题 /// 简化的视频标题
+2 -2
View File
@@ -84,10 +84,10 @@ namespace dy.net.job
if (follows.Count > 0) 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}人。");
} }
} }
} }
+10 -13
View File
@@ -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<List<DouyinCookie>> GetValidCookies() protected override async Task<List<DouyinCookie>> GetValidCookies()
{ {
@@ -56,7 +55,7 @@ namespace dy.net.job
protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed) protected override string CreateSaveFolder(DouyinCookie cookie, Aweme item, AppConfig config, DouyinFollowed followed)
{ {
#region 使UP主名称作为文件夹名称使 #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); var folder = Path.Combine(cookie.UpSavePath, authorName);
if (!string.IsNullOrWhiteSpace(followed.SavePath)) if (!string.IsNullOrWhiteSpace(followed.SavePath))
{ {
@@ -71,7 +70,7 @@ namespace dy.net.job
} }
else 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 (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result;
var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName; var fileNameFolder = string.IsNullOrWhiteSpace(existingName) ? sampleName : existingName;
return Path.Combine(folder, fileNameFolder); return Path.Combine(folder, fileNameFolder);
@@ -115,7 +114,7 @@ namespace dy.net.job
string fileName; string fileName;
if (config?.UperUseViedoTitle ?? false)//优先 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; var (existingName, _) = douyinVideoService.GetUperLastViedoFileName(item.Author.Uid, sampleName).Result;
fileName = string.IsNullOrWhiteSpace(existingName) ? $"{sampleName}.{Format}" : $"{existingName}.{Format}"; fileName = string.IsNullOrWhiteSpace(existingName) ? $"{sampleName}.{Format}" : $"{existingName}.{Format}";
} }
@@ -130,11 +129,11 @@ namespace dy.net.job
Id = item.AwemeId, Id = item.AwemeId,
ReleaseTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime), ReleaseTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
Resolution = $"{Width}×{Height}", Resolution = $"{Width}×{Height}",
VideoTitle = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId), VideoTitle = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId),
Author = item.Author.Nickname Author = item.Author.Nickname
}); });
fileName= $"{DouyinFileNameHelper.SanitizePath(fullName)}.{Format}"; fileName= $"{DouyinFileNameHelper.SanitizeLinuxFileName(fullName)}.{Format}";
} }
else else
{ {
@@ -172,17 +171,17 @@ namespace dy.net.job
return Path.Combine(cookie.UpSavePath, "author"); 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) if (syncCount > 0)
{ {
Serilog.Log.Debug($"{JobType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频"); Serilog.Log.Debug($"{VideoType}-Cookie-[{cookie.UserName}],本次同步成功{syncCount}条视频");
cookie.UperSyncd = 1; cookie.UperSyncd = 1;
await douyinCookieService.UpdateAsync(cookie); await douyinCookieService.UpdateAsync(cookie);
} }
else 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) if (config?.UperUseViedoTitle ?? false)
{ {
simplifiedTitle = DouyinFileNameHelper.GenerateFileName(item.Desc, item.AwemeId); simplifiedTitle = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId);
} }
return new VideoEntityDifferences return new VideoEntityDifferences
{ {
VideoType = VideoTypeEnum.UperPost,
VideoTitleSimplify = simplifiedTitle VideoTitleSimplify = simplifiedTitle
}; };
} }
@@ -6,7 +6,7 @@ namespace dy.net.model
/// 重新下载的列表 /// 重新下载的列表
/// </summary> /// </summary>
[SugarTable(TableName = "dy_rd_video")] [SugarTable(TableName = "dy_rd_video")]
public class ViedoReDown public class DouyinReDownload
{ {
/// <summary> /// <summary>
/// ///
+5
View File
@@ -139,6 +139,11 @@ namespace dy.net.model
[SugarColumn(Length =200,IsNullable =true)] [SugarColumn(Length =200,IsNullable =true)]
public VideoTypeEnum ViedoType { get; set; } public VideoTypeEnum ViedoType { get; set; }
/// <summary>
/// 是否合成视频
/// </summary>
public int IsMergeVideo { get; set; }
[SugarColumn(IsIgnore = true)] [SugarColumn(IsIgnore = true)]
public string ViedoTypeStr => ViedoType.GetDescription(); public string ViedoTypeStr => ViedoType.GetDescription();
+24
View File
@@ -0,0 +1,24 @@
using SqlSugar;
namespace dy.net.model
{
/// <summary>
/// 永久删除的视频记录-不再下载
/// </summary>
[SugarTable(TableName = "dy_delete_video")]
public class DouyinVideoDelete
{
/// <summary>
///
/// </summary>
[SugarColumn(IsPrimaryKey = true)]
public string Id { get; set; }
/// <summary>
/// 原视频Id
/// </summary>
[SugarColumn(IsNullable =true,Length =50)]
public string ViedoId { get; set; }
public DateTime DeleteTime { get; set; }
}
}
+7 -9
View File
@@ -93,11 +93,11 @@ namespace dy.net.repository
/// <param name="followInfos"></param> /// <param name="followInfos"></param>
/// <param name="myselfUserId"></param> /// <param name="myselfUserId"></param>
/// <returns></returns> /// <returns></returns>
public async Task<bool> Sync(List<FollowingsItem> followInfos, string myselfUserId) public async Task<bool> Sync(List<FollowingsItem> followInfos, DouyinCookie ck)
{ {
// 基础参数校验 // 基础参数校验
if (followInfos == null) followInfos = new List<FollowingsItem>(); if (followInfos == null) followInfos = new List<FollowingsItem>();
if (string.IsNullOrWhiteSpace(myselfUserId)) if (ck==null || string.IsNullOrWhiteSpace(ck.MyUserId))
{ {
Serilog.Log.Error("同步关注列表失败:当前用户ID为空"); Serilog.Log.Error("同步关注列表失败:当前用户ID为空");
return false; return false;
@@ -107,7 +107,7 @@ namespace dy.net.repository
{ {
// 1. 查询现有关注列表 // 1. 查询现有关注列表
List<DouyinFollowed> existFollows = await Db.Queryable<DouyinFollowed>() List<DouyinFollowed> existFollows = await Db.Queryable<DouyinFollowed>()
.Where(x => x.mySelfId == myselfUserId) .Where(x => x.mySelfId == ck.MyUserId)
.Where(x=>!x.IsNoFollowed) //排除手动添加但未关注的用户 .Where(x=>!x.IsNoFollowed) //排除手动添加但未关注的用户
.ToListAsync() ?? new List<DouyinFollowed>(); .ToListAsync() ?? new List<DouyinFollowed>();
@@ -161,7 +161,7 @@ namespace dy.net.repository
Id = IdGener.GetLong().ToString(), Id = IdGener.GetLong().ToString(),
Enterprise = follow.EnterpriseVerifyReason, Enterprise = follow.EnterpriseVerifyReason,
LastSyncTime = DateTime.UtcNow, LastSyncTime = DateTime.UtcNow,
mySelfId = myselfUserId, mySelfId = ck.MyUserId,
SecUid = follow.SecUid, SecUid = follow.SecUid,
OpenSync = false, OpenSync = false,
UperAvatar = follow.Avatar?.UrlList?.FirstOrDefault() ?? "", UperAvatar = follow.Avatar?.UrlList?.FirstOrDefault() ?? "",
@@ -201,8 +201,6 @@ namespace dy.net.repository
Serilog.Log.Error("同步关注列表失败:关注信息更新异常"); Serilog.Log.Error("同步关注列表失败:关注信息更新异常");
return false; return false;
} }
Serilog.Log.Debug($"同步关注列表:成功更新{toUpdateFollows.Count}条关注信息(用户ID{myselfUserId}");
} }
// 6. 分批处理删除(单批200条) // 6. 分批处理删除(单批200条)
@@ -212,7 +210,7 @@ namespace dy.net.repository
async batch => async batch =>
{ {
var secUids = batch.Select(x => x.SecUid).ToList(); 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; 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; return true;
} }
catch (Exception ex) catch (Exception ex)
{ {
Serilog.Log.Error(ex, $"同步关注列表失败(用户ID{myselfUserId}):{ex.Message}"); Serilog.Log.Error(ex, $"同步关注列表失败({ck.UserName}):{ex.Message}");
return false; return false;
} }
} }
+10 -8
View File
@@ -33,10 +33,11 @@ namespace dy.net.repository
VideoTypeEnum? enumviedoType = null; VideoTypeEnum? enumviedoType = null;
if (!string.IsNullOrEmpty(dto.ViedoType) && dto.ViedoType != "*") if (!string.IsNullOrEmpty(dto.ViedoType) && dto.ViedoType != "*" && dto.ViedoType != "4")
{ {
enumviedoType = dto.ViedoType.ToVideoTypeEnum(); enumviedoType = dto.ViedoType.ToVideoTypeEnum();
} }
var where = this.Db.Queryable<DouyinVideo>() var where = this.Db.Queryable<DouyinVideo>()
//.WhereIF(!string.IsNullOrWhiteSpace(title), x => x.VideoTitle.Contains(title)) //.WhereIF(!string.IsNullOrWhiteSpace(title), x => x.VideoTitle.Contains(title))
.WhereIF(!string.IsNullOrWhiteSpace(dto.Title), x => x.VideoTitle.Contains(dto.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(end.HasValue, x => x.SyncTime <= end.Value)
.WhereIF(start2.HasValue, x => x.CreateTime >= start2.Value) .WhereIF(start2.HasValue, x => x.CreateTime >= start2.Value)
.WhereIF(end2.HasValue, x => x.CreateTime <= end2.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(); var totalCount = await where.CountAsync();
@@ -90,7 +92,7 @@ namespace dy.net.repository
public async Task<(string, string)> GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify) public async Task<(string, string)> GetUperLastViedoFileName(string AuthorId, string ViedoNameSimplify)
{ {
var video = await this.Db.Queryable<DouyinVideo>().Where(x => x.AuthorId == AuthorId && x.ViedoType == VideoTypeEnum.UperPost) var video = await 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).FirstAsync();
@@ -140,7 +142,7 @@ namespace dy.net.repository
/// </summary> /// </summary>
/// <param name="downs"></param> /// <param name="downs"></param>
/// <returns></returns> /// <returns></returns>
public bool InsertReDowns(List<ViedoReDown> downs) public bool InsertReDowns(List<DouyinReDownload> downs)
{ {
if (downs != null) if (downs != null)
{ {
@@ -169,9 +171,9 @@ namespace dy.net.repository
} }
// 构建更新条件(统一Where条件,避免重复代码) // 构建更新条件(统一Where条件,避免重复代码)
var updateable = Db.Updateable<ViedoReDown>() var updateable = Db.Updateable<DouyinReDownload>()
.Where(it => it.Id == videoId) .Where(it => it.Id == videoId)
.SetColumns(it => new ViedoReDown .SetColumns(it => new DouyinReDownload
{ {
Status = status, Status = status,
UpdateTime = DateTime.Now UpdateTime = DateTime.Now
@@ -197,9 +199,9 @@ namespace dy.net.repository
/// ///
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public async Task<List<ViedoReDown>> GetViedoReDowns() public async Task<List<DouyinReDownload>> GetViedoReDowns()
{ {
return await this.Db.Queryable<ViedoReDown>() return await this.Db.Queryable<DouyinReDownload>()
.Where(x => x.Status == 0 || x.Status == 2) .Where(x => x.Status == 0 || x.Status == 2)
.ToListAsync(); .ToListAsync();
} }
+65 -18
View File
@@ -49,7 +49,7 @@ namespace dy.net.service
LogKeepDay = 10, LogKeepDay = 10,
UperSaveTogether = false,//博主视频:true-->每个视频单独一个文件夹 false-->所有视频放在同一个文件夹 UperSaveTogether = false,//博主视频:true-->每个视频单独一个文件夹 false-->所有视频放在同一个文件夹
UperUseViedoTitle = false,//博主视频:true-->使用视频标题作为文件名 false-->使用视频id作为文件名 UperUseViedoTitle = false,//博主视频:true-->使用视频标题作为文件名 false-->使用视频id作为文件名
DownImageVideo = false,//默认下载图文视频 DownImageVideo = true,//默认下载图文视频
DownMp3 = false, DownMp3 = false,
DownImage = false, DownImage = false,
ImageViedoSaveAlone = true, ImageViedoSaveAlone = true,
@@ -99,18 +99,18 @@ namespace dy.net.service
} }
/// <summary> /// <summary>
/// 兼容旧版将之前同步了的我收藏的数据进行更新 /// 初始化一些数据,兼容旧版数据结构。
/// </summary> /// </summary>
public void UpdateCollectViedoType() public void UpdateCollectViedoType()
{ {
//更新视频类型字段-兼容老版本
string sql = @"UPDATE dy_collect_video string sql = @"UPDATE dy_collect_video
SET ViedoType = CASE SET ViedoType = CASE
WHEN ViedoType = '0' THEN 0 WHEN ViedoType = '0' THEN 0
WHEN ViedoType = '1' THEN 1 WHEN ViedoType = '1' THEN 1
WHEN ViedoType = '2' THEN 2 WHEN ViedoType = '2' THEN 2
WHEN ViedoType = '3' THEN 3 WHEN ViedoType = '3' THEN 3
WHEN ViedoType = '4' THEN 4 WHEN ViedoType = '4' THEN 4
ELSE NULL ELSE NULL
END;"; END;";
@@ -119,16 +119,53 @@ namespace dy.net.service
//更新关注表的IsNoFollowed字段为空的数据为0--兼容老版本-新加的字段 //更新关注表的IsNoFollowed字段为空的数据为0--兼容老版本-新加的字段
string followUpdateSql = @"Update dy_follow SET IsNoFollowed=0 WHERE IsNoFollowed is NULL"; string followUpdateSql = @"Update dy_follow SET IsNoFollowed=0 WHERE IsNoFollowed is NULL";
sqlSugarClient.Ado.ExecuteCommand(followUpdateSql); sqlSugarClient.Ado.ExecuteCommand(followUpdateSql);
//var collectViedos = sqlSugarClient.Queryable<DouyinVideo>().ToList();
//if (collectViedos.Any()) //更新图片视频的合并状态
//{ string updateIsMergeVideoSql = @"UPDATE dy_collect_video SET IsMergeVideo = 1 WHERE IsMergeVideo IS NULL and ViedoType='4';";
// collectViedos.ForEach(x => sqlSugarClient.Ado.ExecuteCommand(updateIsMergeVideoSql);
// {
// x.ViedoType = x.; //更新非图片视频的合并状态
// }); string updateNoIsMergeVideoSql = @"UPDATE dy_collect_video SET IsMergeVideo = 0 WHERE IsMergeVideo IS NULL and ViedoType<>'4';";
// sqlSugarClient.Updateable(collectViedos).ExecuteCommand(); sqlSugarClient.Ado.ExecuteCommand(updateNoIsMergeVideoSql);
//}
//强制开启去重
sqlSugarClient.Updateable<AppConfig>().SetColumns(x => new AppConfig { AutoDistinct = true }).Where(x => !string.IsNullOrWhiteSpace(x.Id)).ExecuteCommand();
//重新根据保存路径更新图片视频的类型为喜欢,收藏或关注
var collectViedos = sqlSugarClient.Queryable<DouyinVideo>().Where(x=>x.ViedoType==VideoTypeEnum.ImageVideo).ToList();
if (collectViedos != null && collectViedos.Any())
{
var cookies= sqlSugarClient.Queryable<DouyinCookie>().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();
}
} }
/// <summary> /// <summary>
/// 重置所有Cookie的同步状态为0 /// 重置所有Cookie的同步状态为0
@@ -148,18 +185,28 @@ namespace dy.net.service
} }
/// <summary> /// <summary>
/// 查询需要下载的所有重下载视频记录 /// 查询是否已删除
/// </summary> /// </summary>
/// <param name="videoId"></param>
/// <returns></returns> /// <returns></returns>
public async Task<List<ViedoReDown>> GetAllRedown() public async Task<bool> ExistDeleteVideo(string videoId)
{ {
return await sqlSugarClient.Queryable<ViedoReDown>().Where(x => x.Status == 0 || x.Status == 2).ToListAsync(); var count= await sqlSugarClient.Queryable<DouyinVideoDelete>().Where(x=>x.ViedoId==videoId).CountAsync();
return count > 0;
} }
public async Task<bool> UpdateRedownStatus(List<ViedoReDown> list) /// <summary>
/// 新增要删除的视频
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
public async Task<bool> 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 #region
///// <summary> ///// <summary>
+3 -3
View File
@@ -55,11 +55,11 @@ namespace dy.net.service
/// ///
/// </summary> /// </summary>
/// <param name="followInfos"></param> /// <param name="followInfos"></param>
/// <param name="myselfUserId"></param> /// <param name="ck"></param>
/// <returns></returns> /// <returns></returns>
public async Task<bool> Sync(List<FollowingsItem> followInfos, string myselfUserId) public async Task<bool> Sync(List<FollowingsItem> followInfos, DouyinCookie ck)
{ {
return await _followRepository.Sync(followInfos, myselfUserId); return await _followRepository.Sync(followInfos, ck);
} }
public async Task<DouyinFollowed> GetByUperId(string uperId,string myUid) public async Task<DouyinFollowed> GetByUperId(string uperId,string myUid)
+108
View File
@@ -15,11 +15,14 @@ namespace dy.net.service
public static readonly string DouYinApi = "https://www.douyin.com/aweme/v1/web/aweme"; public static readonly string DouYinApi = "https://www.douyin.com/aweme/v1/web/aweme";
// 随机数生成器(避免重复实例化,保证随机性) // 随机数生成器(避免重复实例化,保证随机性)
private readonly IHttpClientFactory _clientFactory; private readonly IHttpClientFactory _clientFactory;
// 下载信号量锁:初始计数1,最大并发1(同时只能一个下载任务)
private readonly SemaphoreSlim _downloadSemaphore = new SemaphoreSlim(1, 1);
public DouyinHttpClientService(IHttpClientFactory clientFactory) public DouyinHttpClientService(IHttpClientFactory clientFactory)
{ {
_clientFactory = clientFactory; _clientFactory = clientFactory;
} }
/// <summary> /// <summary>
/// 查询用户收藏的视频 /// 查询用户收藏的视频
/// </summary> /// </summary>
@@ -371,6 +374,111 @@ namespace dy.net.service
} }
} }
/// <summary>
/// 下载文件并保存到本地(支持重试机制+单线程限制,同时只能一个下载)
/// </summary>
/// <param name="videoUrl">文件地址</param>
/// <param name="savePath">保存路径</param>
/// <param name="cookie">请求Cookie</param>
/// <param name="httpclientName">HttpClient名称(默认"dy_down1"</param>
/// <param name="cancellationToken">取消令牌(用于终止任务)</param>
/// <param name="streamTimeout">流读取超时时间(默认60秒)</param>
/// <param name="maxRetryCount">最大重试次数(默认3次)</param>
/// <param name="initialRetryDelay">初始重试延迟(默认1秒,指数退避)</param>
/// <returns>是否下载成功</returns>
//public async Task<bool> 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;
// }
// }
//}
/// <summary> /// <summary>
/// 单次下载尝试(核心下载逻辑) /// 单次下载尝试(核心下载逻辑)
/// </summary> /// </summary>
+1 -1
View File
@@ -370,7 +370,7 @@ namespace dy.net.service
// 延迟清理:给 FFmpeg 进程足够时间释放文件句柄(1秒) // 延迟清理:给 FFmpeg 进程足够时间释放文件句柄(1秒)
await Task.Delay(1000); await Task.Delay(1000);
Directory.Delete(tempDir, recursive: true); Directory.Delete(tempDir, recursive: true);
Log.Debug($"临时目录已清理:{tempDir}"); //Log.Debug($"临时目录已清理:{tempDir}");
} }
catch (Exception ex) catch (Exception ex)
{ {
+277 -243
View File
@@ -1,6 +1,7 @@
using dy.net.dto; using dy.net.dto;
using dy.net.job; using dy.net.job;
using Quartz; using Quartz;
using Quartz.Impl.Matchers;
using Serilog; using Serilog;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -13,242 +14,21 @@ namespace dy.net.service
public class DouyinQuartzJobService public class DouyinQuartzJobService
{ {
private readonly ISchedulerFactory _schedulerFactory; private readonly ISchedulerFactory _schedulerFactory;
private const string DefaultJobGroup = "group1"; private const string DefaultJobGroup = "dysync.net";
private const int DefaultIntervalMinutes = 30; private const int DefaultIntervalMinutes = 30;
private const int DefaultCronStartDelaySeconds = 30; private const int DefaultCronStartDelaySeconds = 30;
private const int DefaultSimpleStartDelaySeconds = 3; private const int DefaultSimpleStartDelaySeconds = 3;
// 任务顺序依赖配置(核心:定义执行顺序,供 Listener 使用)
public Dictionary<string, string> JobDependency { get; } = new()
public DouyinQuartzJobService(ISchedulerFactory schedulerFactory)
{ {
_schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory)); {"collect", "favorite"}, // collect 执行完 → 触发 favorite
} {"favorite", "followed"}, // favorite 执行完 → 触发 followed
{"followed", null}, // followed 执行完 → 一轮任务结束
};
/// <summary> // 任务配置信息(保持原有配置不变,改为 public 供 Listener 访问)
/// 启动所有抖音相关定时任务 public Dictionary<string, JobConfig> JobConfigs { get; } = new()
/// </summary>
/// <param name="expression">Cron表达式或间隔分钟数</param>
/// <param name="delayBetweenJobs">任务之间的启动延迟(毫秒)</param>
/// <returns>是否启动成功</returns>
public async Task<bool> InitOrReStartAllJobs(string expression, int delayBetweenJobs = 5000)
{
if (string.IsNullOrWhiteSpace(expression))
{
Log.Warning("定时任务表达式为空,使用默认配置");
expression = DefaultIntervalMinutes.ToString();
}
// 按顺序启动任务,避免并发
var jobTasks = new List<Task<bool>>
{
//关注列表
//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);
}
/// <summary>
/// 启动关注同步任务(单次执行)
/// </summary>
/// <returns>是否启动成功</returns>
public async Task<bool> StartFollowJobOnceAsync()
{
return await StartOneTimeJobAsync("follow_user_once");
}
/// <summary>
/// 延迟后启动任务
/// </summary>
private async Task<bool> DelayAndStartJobAsync(string jobKey, string expression, int delayMs)
{
if (delayMs > 0)
{
await Task.Delay(delayMs);
}
// 如果是数字表达式,自动递增避免并发
var adjustedExpression = AdjustExpressionForConcurrency(jobKey, expression);
return await StartJobAsync(jobKey, adjustedExpression);
}
/// <summary>
/// 调整任务表达式以避免并发
/// </summary>
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();
}
/// <summary>
/// 启动指定定时任务
/// </summary>
private async Task<bool> 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;
}
}
/// <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)
.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>
/// 创建触发器(支持Cron表达式和简单间隔)
/// </summary>
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();
}
/// <summary>
/// 移除已存在的任务
/// </summary>
private async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey)
{
if (await scheduler.CheckExists(jobKey))
{
Log.Information("移除已存在的任务: {JobKey}", jobKey);
await scheduler.DeleteJob(jobKey);
}
}
/// <summary>
/// 任务配置信息
/// </summary>
private readonly Dictionary<string, JobConfig> _jobConfigs = new()
{ {
{ {
"collect", "collect",
@@ -267,11 +47,11 @@ namespace dy.net.service
"抖音点赞同步任务") "抖音点赞同步任务")
}, },
{ {
"uper", "followed",
new JobConfig( new JobConfig(
typeof(DouyinFollowedViedoSyncJob), typeof(DouyinFollowedViedoSyncJob),
"dy.job.key.uper", "dy.job.key.followed",
"dy.trigger.key.uper", "dy.trigger.key.followed",
"抖音UP主作品同步任务") "抖音UP主作品同步任务")
}, },
{ {
@@ -290,16 +70,270 @@ namespace dy.net.service
"dy.trigger.key.follow_user_once", "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));
}
/// <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 → 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;
}
}
/// <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);
}
}
}
} }
+33 -52
View File
@@ -22,6 +22,11 @@ namespace dy.net.service
} }
public async Task<bool> DeleteById(string Id)
{
return await _dyCollectVideoRepository.DeleteByIdAsync(Id);
}
public async Task<bool> BatchInsertOrUpdate(List<DouyinVideo> videos) public async Task<bool> BatchInsertOrUpdate(List<DouyinVideo> videos)
{ {
// 边界处理:传入列表为空直接返回成功 // 边界处理:传入列表为空直接返回成功
@@ -99,22 +104,22 @@ namespace dy.net.service
CategoryCount = list.Select(x => x.Tag1).Distinct().Count(), CategoryCount = list.Select(x => x.Tag1).Distinct().Count(),
VideoCount = list.Count, VideoCount = list.Count,
Categories = Categories, Categories = Categories,
FavoriteCount = list.Count(x => x.ViedoType == VideoTypeEnum.Favorite), FavoriteCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_favorite),
CollectCount = list.Count(x => x.ViedoType == VideoTypeEnum.Collect), CollectCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_collects),
FollowCount = list.Count(x => x.ViedoType == VideoTypeEnum.UperPost), FollowCount = list.Count(x => x.ViedoType == VideoTypeEnum.dy_follows),
GraphicVideoCount = list.Count(x => x.ViedoType == VideoTypeEnum.ImageVideo), GraphicVideoCount = list.Count(x => x.IsMergeVideo == 1),
VideoSizeTotal = ByteToGbConverter.ConvertBytesToGb(list.Sum(x => x.FileSize)), VideoSizeTotal = ByteToGbConverter.ConvertBytesToGb(list.Sum(x => x.FileSize)),
VideoFavoriteSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.ViedoType == VideoTypeEnum.Favorite).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.Collect).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.UperPost).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.ViedoType == VideoTypeEnum.ImageVideo).Sum(x => x.FileSize)), GraphicVideoSize = ByteToGbConverter.ConvertBytesToGb(list.Where(x => x.IsMergeVideo == 1).Sum(x => x.FileSize)),
//TotalDiskSize= ByteToGbConverter.GetHostTotalDiskSpaceGB(), //TotalDiskSize= ByteToGbConverter.GetHostTotalDiskSpaceGB(),
}; };
if (data.GraphicVideoSize == "0.00") 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误导用户 data.GraphicVideoSize = "<0.01";//避免显示0.00误导用户
} }
@@ -166,6 +171,8 @@ namespace dy.net.service
return await _dyCollectVideoRepository.GetByIdAsync(id); return await _dyCollectVideoRepository.GetByIdAsync(id);
} }
/// <summary> /// <summary>
/// 重新下载选中的视频 /// 重新下载选中的视频
/// </summary> /// </summary>
@@ -194,7 +201,7 @@ namespace dy.net.service
} }
// 3. 构建重新下载记录(提前准备数据,避免事务内耗时操作) // 3. 构建重新下载记录(提前准备数据,避免事务内耗时操作)
var reDownList = new List<ViedoReDown>(); var reDownList = new List<DouyinReDownload>();
var filePathsToDelete = new List<string>(); // 收集待删除文件路径,统一处理 var filePathsToDelete = new List<string>(); // 收集待删除文件路径,统一处理
foreach (var video in videos) 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(), Id = IdGener.GetLong().ToString(),
CreateTime = DateTime.UtcNow, // 统一使用UTC时间,避免时区问题 CreateTime = DateTime.UtcNow, // 统一使用UTC时间,避免时区问题
@@ -233,7 +240,7 @@ namespace dy.net.service
var transactionResult = await _dyCollectVideoRepository.UseTranAsync(async () => var transactionResult = await _dyCollectVideoRepository.UseTranAsync(async () =>
{ {
// 4.1 批量插入重新下载记录(SqlSugar批量插入效率更高) // 4.1 批量插入重新下载记录(SqlSugar批量插入效率更高)
_dyCollectVideoRepository.InsertReDowns(reDownList); _dyCollectVideoRepository.InsertReDowns(reDownList);
// 4.2 批量删除原视频记录(使用视频实际存在的ID,避免无效删除) // 4.2 批量删除原视频记录(使用视频实际存在的ID,避免无效删除)
var actualDeleteIds = videos.Select(v => v.Id).ToList(); var actualDeleteIds = videos.Select(v => v.Id).ToList();
var deleteCount = await _dyCollectVideoRepository.DeleteByIdsAsync(actualDeleteIds); // 建议仓储层提供异步删除方法 var deleteCount = await _dyCollectVideoRepository.DeleteByIdsAsync(actualDeleteIds); // 建议仓储层提供异步删除方法
@@ -242,75 +249,49 @@ namespace dy.net.service
Serilog.Log.Error(e, "数据库事务执行失败:Ids={0}", string.Join(",", videoIds)); Serilog.Log.Error(e, "数据库事务执行失败:Ids={0}", string.Join(",", videoIds));
}); });
// 5. 文件删除(非事务操作,失败不回滚数据库,可根据业务调整) // 5. 文件删除(非事务操作,失败不回滚数据库,可根据业务调整)
// 关键:先通过文件路径获取父文件夹,再删除整个文件夹(含子内容 // 采用异步文件操作,避免同步IO阻塞线程(需.NET 5+支持
foreach (var filePath in filePathsToDelete) foreach (var path in filePathsToDelete)
{ {
try try
{ {
// 1. 验证文件路径有效性 if (File.Exists(path))
if (string.IsNullOrWhiteSpace(filePath))
{ {
Serilog.Log.Error("文件路径为空,跳过文件夹删除"); File.Delete(path); // 异步删除,提升并发性能
continue; Serilog.Log.Debug("视频文件删除成功:Path={0}", path);
}
// 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);
} }
else else
{ {
Serilog.Log.Error("文件不存在,跳过删除:ParentDir={0},关联文件路径={1}", parentDirPath, filePath); Serilog.Log.Error("视频文件不存在,跳过删除:Path={0}", path);
} }
} }
catch (IOException ex) catch (IOException ex)
{ {
Serilog.Log.Error(ex, "文件删除失败IO异常):Filepath={0},父文件夹路径={1}", filePath, Path.GetDirectoryName(filePath)); Serilog.Log.Error(ex, "视频文件删除失败Path={0}", path);
}
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));
} }
} }
var CookieIds = reDownList.Select(x => x.CookieId).Distinct(); var CookieIds = reDownList.Select(x => x.CookieId).Distinct();
foreach (var ck in CookieIds) foreach (var ck in CookieIds)
{ {
var cookie= douyinCookieRepository.GetById(ck); var cookie = douyinCookieRepository.GetById(ck);
if (cookie == null) if (cookie == null)
continue; continue;
var viedoTypes = videos.Where(x => x.CookieId == ck).Select(x => x.ViedoType).Distinct(); 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) foreach (VideoTypeEnum item in viedoTypes)
{ {
switch (item) switch (item)
{ {
case VideoTypeEnum.Favorite: case VideoTypeEnum.dy_favorite:
cookie.FavHasSyncd = 0; cookie.FavHasSyncd = 0;
break; break;
case VideoTypeEnum.Collect: case VideoTypeEnum.dy_collects:
cookie.CollHasSyncd = 0; cookie.CollHasSyncd = 0;
break; break;
case VideoTypeEnum.UperPost: case VideoTypeEnum.dy_follows:
cookie.UperSyncd = 0; cookie.UperSyncd = 0;
break; break;
case VideoTypeEnum.ImageVideo: case VideoTypeEnum.ImageVideo:
@@ -338,7 +319,7 @@ namespace dy.net.service
/// 获取待重新下载的视频列表 /// 获取待重新下载的视频列表
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
public async Task<List<ViedoReDown>> GetViedoReDowns() public async Task<List<DouyinReDownload>> GetViedoReDowns()
{ {
return await _dyCollectVideoRepository.GetViedoReDowns(); return await _dyCollectVideoRepository.GetViedoReDowns();
} }
+27 -138
View File
@@ -10,156 +10,45 @@ namespace dy.net.utils
/// </summary> /// </summary>
public static class DouyinFileNameHelper public static class DouyinFileNameHelper
{ {
#region
/// <summary> /// <summary>
/// 处理文件名/文件夹名,确保符合 Linux 限制(最大 255 字节 UTF-8 编码)
/// </summary> /// </summary>
private const int MaxFileNameBytes = 60; /// <param name="originalName">原始名称(支持英文、中文、混合字符)</param>
/// <param name="defaultName">截取后为空时的默认名称(默认 "default"</param>
/// <returns>符合 Linux 规则的合法名称</returns>
public static string SanitizeLinuxFileName(string originalName, string defaultName = "default")
/// <summary>
/// 非法字符替换后的占位符(也可设为空字符串)
/// </summary>
private const string IllegalCharReplacement = "";
#endregion
#region
/// <summary>
/// 生成抖音视频文件名
/// </summary>
/// <param name="originalTitle">抖音原始标题</param>
/// <param name="videoId">排序号--如果有重复标题,根据时间排序取最后一个的序号+1 从001开始</param>
/// <returns>安全可用的文件名</returns>
public static string GenerateFileName(string originalTitle,string videoId)
{ {
// 1. 基础容错(处理空值) // 1. 空值处理:直接返回默认名
originalTitle = string.IsNullOrWhiteSpace(originalTitle) ? videoId : originalTitle; if (string.IsNullOrWhiteSpace(originalName))
return defaultName.Replace(" ","");
// 2. 净化标题:移除非法内容 // 2. 过滤 Linux 非法字符:
string purifiedTitle = PurifyTitle(originalTitle, videoId); // - 禁止:/(路径分隔符)、\0(空字符)
// - 替换:其他特殊字符(如 :*?"<>|\\ )为下划线 _,避免创建失败
// 3. 长度控制:按UTF-8字节数截断(避免超系统限制) var invalidChars = new[] { '/', '\0', ':', '*', '?', '"', '<', '>', '|', '\\' };
string truncatedTitle = TruncateByByteLength(purifiedTitle, MaxFileNameBytes); string sanitizedName = originalName;
foreach (var c in invalidChars)
return truncatedTitle.Trim();
}
#endregion
#region
/// <summary>
/// 净化标题(移除非法字符、表情、话题标签)
/// </summary>
private static string PurifyTitle(string title,string id)
{
if (string.IsNullOrEmpty(title))
{ {
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]", "-"); // 4. 超过 255 字节,截取前 255 字节(避免破坏 UTF-8 字符)
// 步骤1:移除话题标签(#xxx 或 #xxx#yyy byte[] truncatedBytes = new byte[252];
//title = Regex.Replace(title, @"#\S+", "", RegexOptions.Compiled); Array.Copy(utf8Bytes, truncatedBytes, 252);
// 步骤2:移除表情符号(匹配常见表情Unicode区块 // 5. 字节数组转回字符串(自动忽略不完整的尾部字节,避免乱码
//string emojiPattern = @"[\u1F600-\u1F64F\u1F300-\u1F5FF\u1F680-\u1F6FF\u1E000-\u1EFFF\u2600-\u2B55\u200D]"; string truncatedName = Encoding.UTF8.GetString(truncatedBytes).TrimEnd('\0').Replace(" ",""); // 移除可能的空字符
//title = Regex.Replace(title, emojiPattern, "", RegexOptions.Compiled);
// 步骤3:过滤系统非法字符(Windows/macOS/Linux通用禁止) // 6. 极端情况:截取后为空(如全是非法字符替换后无有效内容),返回默认名
char[] illegalChars = new[] { '/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0', '\t', '\n', '\r' }; return string.IsNullOrWhiteSpace(truncatedName) ? defaultName : truncatedName;
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
/// <summary>
/// 按UTF-8字节数截断字符串(避免截断半个中文)
/// </summary>
/// <param name="str">要截断的字符串</param>
/// <param name="maxBytes">最大字节数</param>
/// <returns>截断后的字符串</returns>
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;
}
} }
/// <summary>
/// 检查字符串是否仅包含字母、数字、简体中文(无特殊字符) /// 检查字符串是否仅包含字母、数字、简体中文(无特殊字符)
/// </summary> /// </summary>
/// <param name="input">待检查的字符串</param> /// <param name="input">待检查的字符串</param>
@@ -184,4 +73,4 @@ namespace dy.net.utils
return Regex.IsMatch(input, pattern, RegexOptions.None); return Regex.IsMatch(input, pattern, RegexOptions.None);
} }
} }
} }