From b328e82eb27b0d03b49cae6f4ac980ffa30a70e4 Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 25 Apr 2026 00:22:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=86=E9=A2=91=E9=A2=84=E8=A7=88/?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E7=9C=8B=E6=9D=BF/=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20=E4=B8=89=E9=A1=B9=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增视频预览:VideoPlayer 组件、VideoController/DyCollectVideoService/Repository、VideoPageRequestDTO 对应后端改动 - 新增数据看板:workplace/statics.vue、RecordTable、相关 store/http/axios 改动 - 新增下载配置:DownloadConfigService、ConfigController、AppSet.vue、AppConfig - 其他:DyHttpClientService / 同步 Job(Collect, Favorit) / Cookie 表 / CommonService / ServiceExtension 的配套调整 - 仓库卫生:.gitignore 追加 feiniu 过滤;运维脚本归档到 docs/scripts/ --- .gitignore | 3 + Controllers/ConfigController.cs | 51 +- Controllers/VideoController.cs | 270 ++++- app/.env.development | 3 +- app/src/components/video/VideoPlayer.vue | 580 +++++++++++ app/src/pages/cok/CookieTable.vue | 227 ++--- app/src/pages/set/AppSet.vue | 90 +- app/src/pages/workplace/RecordTable.vue | 418 +++++++- app/src/pages/workplace/statics.vue | 1149 +++++++++++++++++++++- app/src/store/coreapi.ts | 42 +- app/src/store/http.ts | 11 +- app/src/utils/axiosHttp.ts | 42 +- app/vite.config.ts | 4 +- docs/scripts/check_database.sh | 39 + docs/scripts/安装ASP.NET运行时.sh | 13 + docs/scripts/安装x64运行时.sh | 13 + docs/scripts/检查数据库数据.py | 81 ++ dto/VideoPageRequestDTO.cs | 18 + extension/ServiceExtension.cs | 16 +- job/DouYinCollectSyncJob.cs | 137 +-- job/DouYinFavoritSyncJob.cs | 138 +-- model/AppConfig.cs | 36 + repository/DyCollectVideoRepository.cs | 207 +++- service/CommonService.cs | 7 + service/DownloadConfigService.cs | 124 +++ service/DyCollectVideoService.cs | 196 +++- service/DyHttpClientService.cs | 35 +- 27 files changed, 3602 insertions(+), 348 deletions(-) create mode 100644 app/src/components/video/VideoPlayer.vue create mode 100644 docs/scripts/check_database.sh create mode 100755 docs/scripts/安装ASP.NET运行时.sh create mode 100755 docs/scripts/安装x64运行时.sh create mode 100644 docs/scripts/检查数据库数据.py create mode 100644 service/DownloadConfigService.cs diff --git a/.gitignore b/.gitignore index 638a8ac..d4e673a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ videos/ /db/*.db-journal /db/*.db-wal /db/*.db-shm + +# 飞牛 NAS 相关(当前只部署群晖,不入库) +*feiniu* diff --git a/Controllers/ConfigController.cs b/Controllers/ConfigController.cs index 00a23c9..e98b567 100644 --- a/Controllers/ConfigController.cs +++ b/Controllers/ConfigController.cs @@ -136,7 +136,7 @@ namespace dy.net.Controllers } /// - /// + /// 立即执行同步任务 /// /// [HttpGet("ExecuteJobNow")] @@ -148,6 +148,55 @@ namespace dy.net.Controllers return Ok(new { code = 0 , error = "" }); } + /// + /// 重置指定Cookie的同步状态,强制重新同步 + /// + /// Cookie ID + /// 重置类型:collect=收藏, favorite=喜欢, all=全部 + /// + [Authorize] + [HttpPost("ResetSyncStatus")] + public async Task ResetSyncStatus(string id, string type = "all") + { + try + { + var cookie = await dyCookieService.GetByIdAsync(id); + if (cookie == null) + { + return BadRequest(new { code = -1, error = "Cookie不存在" }); + } + + if (type == "collect" || type == "all") + { + cookie.CollHasSyncd = 0; + } + if (type == "favorite" || type == "all") + { + cookie.FavHasSyncd = 0; + } + if (type == "all") + { + cookie.UperSyncd = 0; + } + + var result = await dyCookieService.UpdateAsync(cookie); + if (result) + { + Serilog.Log.Information($"重置Cookie[{cookie.UserName}]的同步状态,类型: {type}"); + return Ok(new { code = 0, message = "同步状态已重置" }); + } + else + { + return BadRequest(new { code = -1, error = "重置失败" }); + } + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "重置同步状态失败"); + return StatusCode(500, new { code = -1, error = "重置失败:" + ex.Message }); + } + } + private async Task ReStartJob() { var config= commonService.GetConfig(); diff --git a/Controllers/VideoController.cs b/Controllers/VideoController.cs index a380fbd..71535cc 100644 --- a/Controllers/VideoController.cs +++ b/Controllers/VideoController.cs @@ -1,4 +1,4 @@ -using dy.net.dto; +using dy.net.dto; using dy.net.service; using dy.net.utils; using Microsoft.AspNetCore.Authorization; @@ -25,7 +25,10 @@ namespace dy.net.Controllers [HttpPost("paged")] public async Task GetPagedAsync(VideoPageRequestDTO dto) { - var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto.PageIndex, dto.PageSize, dto.Tag, dto.Author,dto.ViedoType,dto.Dates); + // 调试日志:输出接收到的排序参数 + Console.WriteLine($"排序参数 - SortField: {dto.SortField}, SortOrder: {dto.SortOrder}"); + + var (list, totalCount) = await dyCollectVideoService.GetPagedAsync(dto.PageIndex, dto.PageSize, dto.Tag, dto.Tags, dto.Author, dto.ViedoType, dto.Dates, dto.IsDeleted, dto.SortField, dto.SortOrder); return Ok(new { code = 0, @@ -38,6 +41,21 @@ namespace dy.net.Controllers } }); } + + /// + /// 获取所有视频类型列表(用于下拉框) + /// + /// + [HttpGet("categories")] + public async Task GetAllCategories() + { + var categories = await dyCollectVideoService.GetAllCategoriesAsync(); + return Ok(new + { + code = 0, + data = categories + }); + } /// /// 查询统计数据 @@ -53,6 +71,254 @@ namespace dy.net.Controllers data }); } + + /// + /// 清理数据库中文件不存在的视频记录 + /// + /// + [Authorize] + [HttpPost("cleanup")] + public async Task CleanupMissingFilesAsync() + { + try + { + Serilog.Log.Information("开始清理文件不存在的视频记录"); + var deletedCount = await dyCollectVideoService.CleanupMissingFilesAsync(); + Serilog.Log.Information($"清理完成,删除了 {deletedCount} 条记录"); + return Ok(new + { + code = 0, + data = new + { + deletedCount = deletedCount, + message = $"清理完成,删除了 {deletedCount} 条文件不存在的记录" + } + }); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "清理文件不存在的记录失败"); + return StatusCode(500, new { code = -1, error = "清理失败:" + ex.Message }); + } + } + + /// + /// 删除指定作者的所有视频数据 + /// + /// 作者名称 + /// + [Authorize] + [HttpPost("delete-author")] + public async Task DeleteAuthorVideosAsync([FromBody] DeleteAuthorRequest request) + { + try + { + if (string.IsNullOrWhiteSpace(request?.AuthorName)) + { + return BadRequest(new { code = -1, error = "作者名称不能为空" }); + } + + Serilog.Log.Information($"开始删除作者 {request.AuthorName} 的视频数据"); + var deletedCount = await dyCollectVideoService.DeleteAuthorVideosAsync(request.AuthorName); + Serilog.Log.Information($"删除完成,删除了 {deletedCount} 条记录"); + + return Ok(new + { + code = 0, + data = new + { + deletedCount = deletedCount, + message = $"已删除作者 {request.AuthorName} 的 {deletedCount} 条视频记录" + } + }); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, $"删除作者视频失败: {request?.AuthorName}"); + return StatusCode(500, new { code = -1, error = "删除失败:" + ex.Message }); + } + } + + /// + /// 删除作者请求DTO + /// + public class DeleteAuthorRequest + { + public string AuthorName { get; set; } + } + + + /// + /// 获取视频流(支持Range请求,用于视频播放) + /// + /// 视频ID + /// + [Authorize] + [HttpGet("stream/{id}")] + public async Task GetVideoStream(string id) + { + try + { + var video = await dyCollectVideoService.GetByIdAsync(id); + if (video == null || string.IsNullOrWhiteSpace(video.VideoSavePath)) + { + return NotFound(new { code = -1, error = "视频不存在" }); + } + + if (!System.IO.File.Exists(video.VideoSavePath)) + { + return NotFound(new { code = -1, error = "视频文件不存在" }); + } + + var fileInfo = new FileInfo(video.VideoSavePath); + var contentType = GetContentType(video.VideoSavePath); + + // 记录日志,便于调试 + var rangeHeader = Request.Headers["Range"].ToString(); + Serilog.Log.Debug($"视频流请求: ID={id}, 路径={video.VideoSavePath}, Content-Type={contentType}, Range={rangeHeader}"); + + var fileStream = new FileStream(video.VideoSavePath, FileMode.Open, FileAccess.Read, FileShare.Read); + + // 使用ASP.NET Core内置的Range支持 + Response.Headers.Add("Accept-Ranges", "bytes"); + + // 添加缓存控制头,确保浏览器可以缓存视频元数据 + // 但允许 Range 请求,这样浏览器可以快速开始播放 + Response.Headers.Add("Cache-Control", "public, max-age=3600"); + + // 添加 CORS 头(如果需要) + Response.Headers.Add("Access-Control-Allow-Origin", "*"); + Response.Headers.Add("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); + Response.Headers.Add("Access-Control-Allow-Headers", "Range"); + Response.Headers.Add("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Length"); + + return File(fileStream, contentType, enableRangeProcessing: true); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, $"获取视频流失败: {id}"); + return StatusCode(500, new { code = -1, error = "获取视频流失败" }); + } + } + + /// + /// 获取视频封面图片 + /// + /// 视频ID + /// + [Authorize] + [HttpGet("cover/{id}")] + public async Task GetVideoCover(string id) + { + try + { + var video = await dyCollectVideoService.GetByIdAsync(id); + if (video == null) + { + return GetDefaultCoverImage(); + } + + // 优先使用本地保存的封面 + if (!string.IsNullOrWhiteSpace(video.VideoCoverSavePath) && System.IO.File.Exists(video.VideoCoverSavePath)) + { + var fileInfo = new FileInfo(video.VideoCoverSavePath); + var contentType = GetImageContentType(video.VideoCoverSavePath); + var fileStream = System.IO.File.OpenRead(video.VideoCoverSavePath); + return File(fileStream, contentType); + } + + // 如果没有本地封面,返回默认图片 + return GetDefaultCoverImage(); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, $"获取视频封面失败: {id}"); + return GetDefaultCoverImage(); + } + } + + /// + /// 返回默认封面图片(SVG 占位图) + /// + private IActionResult GetDefaultCoverImage() + { + var svgContent = @" + + 暂无封面 +"; + var svgBytes = System.Text.Encoding.UTF8.GetBytes(svgContent); + return File(svgBytes, "image/svg+xml"); + } + + /// + /// 根据文件扩展名获取图片 Content-Type + /// + private string GetImageContentType(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return "image/jpeg"; + } + + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + return extension switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + ".bmp" => "image/bmp", + _ => "image/jpeg" + }; + } + + + /// + /// 根据文件扩展名获取Content-Type + /// + private string GetContentType(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + Serilog.Log.Warning($"文件名为空,使用默认 video/mp4"); + return "video/mp4"; // 抖音视频默认是 MP4 + } + + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + + // 如果没有扩展名,尝试从文件名推断(抖音视频格式通常是 mp4) + if (string.IsNullOrEmpty(extension)) + { + Serilog.Log.Warning($"文件 {fileName} 没有扩展名,使用默认 video/mp4"); + return "video/mp4"; + } + + var contentType = extension switch + { + ".mp4" => "video/mp4", + ".webm" => "video/webm", + ".ogg" => "video/ogg", + ".mov" => "video/quicktime", + ".avi" => "video/x-msvideo", + ".mkv" => "video/x-matroska", + ".flv" => "video/x-flv", + ".wmv" => "video/x-ms-wmv", + ".3gp" => "video/3gpp", + ".m3u8" => "application/x-mpegURL", // HLS 流 + _ => null + }; + + // 如果无法识别扩展名,默认使用 video/mp4(抖音视频通常是 MP4) + if (contentType == null) + { + Serilog.Log.Warning($"无法识别的视频扩展名: {extension},文件: {fileName},使用默认 video/mp4"); + return "video/mp4"; + } + + return contentType; + } + [AllowAnonymous] [HttpGet] public IActionResult Index() diff --git a/app/.env.development b/app/.env.development index c4d5c01..1a1c4ba 100644 --- a/app/.env.development +++ b/app/.env.development @@ -1,2 +1 @@ -VITE_BASE_URL=/ -VITE_API_URL=http://localhost:10101 \ No newline at end of file +VITE_API_URL=http://localhost:10101 diff --git a/app/src/components/video/VideoPlayer.vue b/app/src/components/video/VideoPlayer.vue new file mode 100644 index 0000000..f5f6bed --- /dev/null +++ b/app/src/components/video/VideoPlayer.vue @@ -0,0 +1,580 @@ + + + + + + diff --git a/app/src/pages/cok/CookieTable.vue b/app/src/pages/cok/CookieTable.vue index 7408f4d..032789f 100644 --- a/app/src/pages/cok/CookieTable.vue +++ b/app/src/pages/cok/CookieTable.vue @@ -13,17 +13,12 @@ const columns = [ { title: 'Cookie名', dataIndex: 'userName', - width: 100, + width: 120, }, { title: '状态', dataIndex: 'status', width: 80 }, - { title: '收藏路径', dataIndex: 'savePath', width: 100 }, - { title: '喜欢路径', dataIndex: 'favSavePath', width: 100 }, - { title: 'Up主路径', dataIndex: 'upSavePath', width: 100 }, - // { title: 'Cookie', dataIndex: 'cookies' }, - { title: 'Up主secUserId', dataIndex: 'upSecUserIds' }, - { title: '用户secUserId', dataIndex: 'secUserId' }, - { title: '操作', dataIndex: 'edit', width: 200 }, - // { title: 'id', dataIndex: 'id', width: 200, hiden: false }, + { title: '存储路径', dataIndex: 'paths', width: 300, ellipsis: true }, + { title: 'SecUserId', dataIndex: 'userIds', width: 200, ellipsis: true }, + { title: '操作', dataIndex: 'edit', width: 180, fixed: 'right' }, ]; // 定义数组中单个对象的类型:包含uper和uid字段(可选字符串类型,根据实际需求调整是否可选) @@ -47,16 +42,6 @@ type DataItem = { upSavePath?: string; }; -// const dataSource = reactive([ -// { -// userName: 'Li Zhi', -// cookies: '131231', -// savePath: 'x', -// status: 1, -// id: '1', -// }, -// ]); - const loading = ref(false); const datas: UnwrapRef = reactive([]); const pagination = ref({ @@ -153,9 +138,6 @@ function submit() { } else { copyObject(editRecord.value, resData); } - // alert(JSON.stringify(form.upSecUserIdsJson)); - // console.log('1', authors); - // console.log('2', res); useApiStore() .UpdateConfig(resData) .then((res) => { @@ -177,17 +159,15 @@ function submit() { const editRecord = ref(); -import { Modal } from 'ant-design-vue'; // 假设使用Ant Design Vue的Modal组件 +import { Modal } from 'ant-design-vue'; const deleted = (id: string) => { - // 显示确认对话框 Modal.confirm({ title: '确认删除', content: '确定要删除这条记录吗?此操作不可撤销。', okText: '确认', cancelText: '取消', onOk: () => { - // 用户确认后执行删除操作 useApiStore() .deleteCookie(id) .then((res) => { @@ -200,15 +180,11 @@ const deleted = (id: string) => { }); }, onCancel: () => { - // 用户取消删除,不执行任何操作 console.log('已取消删除'); }, }); }; -/** - * 编辑 - * @param record - */ + function edit(record: DataItem) { editRecord.value = record; copyObject(form, record); @@ -264,7 +240,7 @@ const rowCount = 4;
- +
@@ -272,7 +248,7 @@ const rowCount = 4;
- +
@@ -287,8 +263,7 @@ const rowCount = 4;
- - + 添加抖音Up主 -
- - - - -
同步全部作品
- -