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 @@ + + + + + {{ modalTitle }} + + + + + + + 作者: + {{ currentVideoInfo.author || '未知' }} + + + 分类: + {{ currentVideoInfo.viedoCate || '未分类' }} + + + 分辨率: + {{ currentVideoInfo.resolution || '未知' }} + + + 文件大小: + {{ formatFileSize(currentVideoInfo.fileSize || 0) }} + + + + + + + 您的浏览器不支持视频播放 + + + + + + + + + + + + + + + + + + + + + + + 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主 @@ -296,23 +271,15 @@ const rowCount = 4; - - - - - - 同步全部作品 - - @@ -330,10 +297,8 @@ const rowCount = 4; - - + - @@ -359,23 +324,51 @@ const rowCount = 4; - - - 查看 + + + + 收藏: + + {{ record.savePath }} + + + + 喜欢: + + {{ record.favSavePath }} + + + + Up主: + + {{ record.upSavePath }} + + + - - - 查看 + + + + 用户: + + {{ record.secUserId }} + + + + Up主: + 查看 + + - + 编辑 - + @@ -388,17 +381,13 @@ const rowCount = 4; - - {{ showCookiesData || '无Cookie数据' }} - - {{ showUpersData || '未设置' }} @@ -414,100 +403,114 @@ const rowCount = 4; box-sizing: border-box; } -/* 透明滚动条样式 - WebKit内核浏览器 */ -.cookie-content::-webkit-scrollbar { - width: 6px; /* 更细的滚动条 */ -} - -/* 轨道完全透明 */ -.cookie-content::-webkit-scrollbar-track { - background: transparent; -} - -/* 滑块半透明(默认几乎看不见) */ -.cookie-content::-webkit-scrollbar-thumb { - background: rgba(150, 150, 150, 0.2); /* 浅灰透明 */ - border-radius: 3px; -} - -/* hover时稍微显示一点 */ -.cookie-content::-webkit-scrollbar-thumb:hover { - background: rgba(150, 150, 150, 0.4); /* 略深一点的透明 */ -} - -/* 角落也透明 */ -.cookie-content::-webkit-scrollbar-corner { - background: transparent; -} - -/* Firefox 透明滚动条适配 */ -.cookie-content { - scrollbar-width: thin; - scrollbar-color: rgba(150, 150, 150, 0.2) transparent; -} - -.cookie-content { - white-space: pre-wrap; - word-break: break-all; - max-height: 400px; - overflow-y: auto; - padding: 10px; - box-sizing: border-box; -} .cookie-content::-webkit-scrollbar { width: 6px; } + .cookie-content::-webkit-scrollbar-track { background: transparent; } + .cookie-content::-webkit-scrollbar-thumb { background: rgba(150, 150, 150, 0.2); border-radius: 3px; } + .cookie-content::-webkit-scrollbar-thumb:hover { background: rgba(150, 150, 150, 0.4); } + .cookie-content::-webkit-scrollbar-corner { background: transparent; } + .cookie-content { scrollbar-width: thin; scrollbar-color: rgba(150, 150, 150, 0.2) transparent; } -/* ---------------------- 新增:a-textarea 透明滚动条 ---------------------- */ -/* 1. 穿透 scoped,定位 a-textarea 内部的原生 textarea 元素 */ :deep(.ant-input-textarea-input) { - /* 确保内容超出时显示滚动条(a-textarea 默认已配置,可省略) */ overflow-y: auto; - /* Firefox 透明滚动条:thin 细滚动条 + 滑块颜色/轨道颜色 */ scrollbar-width: thin; scrollbar-color: rgba(150, 150, 150, 0.2) transparent; } -/* 2. WebKit 浏览器(Chrome/Safari/Edge)透明滚动条 */ -/* 滚动条宽度 */ :deep(.ant-input-textarea-input)::-webkit-scrollbar { - width: 6px; /* 与 cookie-content 保持一致的细滚动条 */ - height: 6px; /* 横向滚动条(如需) */ + width: 6px; + height: 6px; } -/* 滚动条轨道(完全透明) */ :deep(.ant-input-textarea-input)::-webkit-scrollbar-track { background: transparent; } -/* 滚动条滑块(半透明,hover 时加深) */ :deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb { - background: rgba(150, 150, 150, 0.2); /* 浅灰透明,默认几乎看不见 */ - border-radius: 3px; /* 圆角优化 */ + background: rgba(150, 150, 150, 0.2); + border-radius: 3px; } :deep(.ant-input-textarea-input)::-webkit-scrollbar-thumb:hover { - background: rgba(150, 150, 150, 0.4); /* hover 时略深,提升交互感知 */ + background: rgba(150, 150, 150, 0.4); } -/* 滚动条角落(完全透明,避免留白) */ :deep(.ant-input-textarea-input)::-webkit-scrollbar-corner { background: transparent; } - \ No newline at end of file + +.path-info { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + line-height: 1.5; +} + +.path-row { + display: flex; + align-items: flex-start; + gap: 4px; +} + +.path-label { + font-weight: 500; + color: #666; + white-space: nowrap; + min-width: 48px; +} + +.path-value { + color: #333; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.userid-info { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + line-height: 1.5; +} + +.userid-row { + display: flex; + align-items: center; + gap: 4px; +} + +.userid-label { + font-weight: 500; + color: #666; + white-space: nowrap; + min-width: 48px; +} + +.userid-value { + color: #333; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + diff --git a/app/src/pages/set/AppSet.vue b/app/src/pages/set/AppSet.vue index 6b571ca..78f4566 100644 --- a/app/src/pages/set/AppSet.vue +++ b/app/src/pages/set/AppSet.vue @@ -16,6 +16,47 @@ + 下载配置 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 修改配置 @@ -43,11 +84,23 @@ interface FormState { Cron: string; Id: string; BatchCount: number; + EnableAutoDownload?: boolean; + DownloadDaysRange?: number; + AuthorWhitelist?: string; + AuthorBlacklist?: string; + DownloadFavorite?: boolean; + DownloadCollect?: boolean; } const formState: UnwrapRef = reactive({ Cron: '30', Id: '0', BatchCount: 10, + EnableAutoDownload: false, + DownloadDaysRange: 0, + AuthorWhitelist: '', + AuthorBlacklist: '', + DownloadFavorite: false, + DownloadCollect: false, }); const rules: Record = { @@ -62,6 +115,21 @@ const getConfig = () => { formState.Cron = res.data.cron; formState.Id = res.data.id; formState.BatchCount = res.data.batchCount; + // 下载配置 + formState.EnableAutoDownload = res.data.enableAutoDownload ?? false; + formState.DownloadDaysRange = res.data.downloadDaysRange ?? 0; + try { + formState.AuthorWhitelist = res.data.authorWhitelist ? JSON.parse(res.data.authorWhitelist).join('\n') : ''; + } catch { + formState.AuthorWhitelist = ''; + } + try { + formState.AuthorBlacklist = res.data.authorBlacklist ? JSON.parse(res.data.authorBlacklist).join('\n') : ''; + } catch { + formState.AuthorBlacklist = ''; + } + formState.DownloadFavorite = res.data.downloadFavorite ?? false; + formState.DownloadCollect = res.data.downloadCollect ?? false; } else { message.error(res.erro, 8); } @@ -79,12 +147,32 @@ const onSubmit = () => { .validate() .then(() => { console.log('values', formState, toRaw(formState)); + // 处理作者白名单和黑名单,转换为JSON数组 + const submitData = { ...toRaw(formState) }; + if (submitData.AuthorWhitelist) { + const whitelist = submitData.AuthorWhitelist.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + submitData.AuthorWhitelist = whitelist.length > 0 ? JSON.stringify(whitelist) : ''; + } else { + submitData.AuthorWhitelist = ''; + } + if (submitData.AuthorBlacklist) { + const blacklist = submitData.AuthorBlacklist.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0); + submitData.AuthorBlacklist = blacklist.length > 0 ? JSON.stringify(blacklist) : ''; + } else { + submitData.AuthorBlacklist = ''; + } + useApiStore() - .apiUpdateConfig(toRaw(formState)) + .apiUpdateConfig(submitData) .then((res) => { if (res.code === 0) { message.success('配置修改生效,同步任务将按照新的规则执行'); componentDisabled.value = true; + getConfig(); // 重新加载配置 } else { message.error(res.erro, 8); } diff --git a/app/src/pages/workplace/RecordTable.vue b/app/src/pages/workplace/RecordTable.vue index 9aa09b9..5df9d69 100644 --- a/app/src/pages/workplace/RecordTable.vue +++ b/app/src/pages/workplace/RecordTable.vue @@ -7,6 +7,19 @@ + + + + 全部 @@ -15,69 +28,165 @@ 关注的 + + + 全部 + 未删除 + 已删除 + + - 查询 + 查询 立即重新同步 - + + + + + + 已删除 + + + + 预览 + + + + + + + diff --git a/app/src/pages/workplace/statics.vue b/app/src/pages/workplace/statics.vue index 5d7b0a8..475b357 100644 --- a/app/src/pages/workplace/statics.vue +++ b/app/src/pages/workplace/statics.vue @@ -135,13 +135,37 @@ + + + 清理无效记录 + + + + 刷新数据 + + - + + + + + @@ -159,7 +183,7 @@ - + @@ -186,12 +210,203 @@ + + + + + + + + + + + + + + + + {{ coverImageLoading.has(video.id) ? '加载中...' : '暂无封面' }} + + + + + + + {{ formatDuration(video.duration) }} + + + + {{ video.videoTitle || '未命名视频' }} + + {{ video.viedoCate || '未分类' }} + {{ video.syncTimeStr }} + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ coverImageLoading.has(video.id) ? '加载中...' : '暂无封面' }} + + + + + + + {{ formatDuration(video.duration) }} + + + + {{ video.videoTitle || '未命名视频' }} + + {{ video.viedoCate || '未分类' }} + {{ video.syncTimeStr }} + + + + + + + + + + + + + + + + + + + + 是否删除该作者「{{ selectedAuthorForDelete?.name }}」的数据? + + + 您可以选择以下操作: + + + + 删除并加入黑名单(不再同步) + + + 仅删除当前数据(后续继续同步) + + + + + + + \ No newline at end of file diff --git a/app/src/store/coreapi.ts b/app/src/store/coreapi.ts index c5bf1c4..2a26e67 100644 --- a/app/src/store/coreapi.ts +++ b/app/src/store/coreapi.ts @@ -138,6 +138,34 @@ export const useApiStore = defineStore('coreapi', () => { }); } + + // 清理数据库中文件不存在的视频记录 + async function VideoCleanup() { + return http.request>('/api/video/cleanup', 'post_json').then(r => { + return r.data; + }).finally(() => { + + }); + } + + // 获取所有视频类型列表 + async function VideoCategories() { + return http.request>('/api/video/categories', 'get').then(r => { + console.log('VideoCategories 原始响应:', r); + // 响应拦截器已经处理了 code === 0 的情况,直接返回 data + // 如果 r 本身就是 Response 对象,返回 r.data + // 如果 r 已经是 data(拦截器处理后的结果),直接返回 + if (r && typeof r === 'object' && 'code' in r && 'data' in r) { + return r.data; + } + return r; + }).catch(err => { + console.error('VideoCategories API 错误:', err); + throw err; + }).finally(() => { + + }); + } //cookies async function CookiePageList(param: object) { return http.request>('/api/config/paged', 'post_json', param).then(r => { @@ -163,6 +191,15 @@ export const useApiStore = defineStore('coreapi', () => { }); } + // 删除指定作者的所有视频数据 + async function DeleteAuthorVideos(authorName: string) { + return http.request>('/api/video/delete-author', 'post_json', { authorName }).then(r => { + return r.data; + }).finally(() => { + + }); + } + return { deleteCookie, UpdateConfig, @@ -176,6 +213,9 @@ export const useApiStore = defineStore('coreapi', () => { StartJobNow, VideoStatics, VideoPageList, - CookiePageList + VideoCategories, + VideoCleanup, + CookiePageList, + DeleteAuthorVideos }; }); diff --git a/app/src/store/http.ts b/app/src/store/http.ts index 6146868..5442fc7 100644 --- a/app/src/store/http.ts +++ b/app/src/store/http.ts @@ -41,13 +41,20 @@ http.interceptors.response.use( (error) => { // console.log(error) // debugger - if (error.response.status === 401) { - useAccountStore().setLogged(false) + if (error.response && error.response.status === 401) { + useAccountStore().setLogged(false); + return Promise.reject({ + message: '未授权,请重新登录', + code: 401, + error: 'Unauthorized', + data: error.response.data, + }); } else { if (error.response && isAxiosResponse(error.response)) { return Promise.reject({ message: error.response.statusText, code: error.response.status, + error: error.response.data?.error || error.response.statusText, data: error.response.data, }); } diff --git a/app/src/utils/axiosHttp.ts b/app/src/utils/axiosHttp.ts index 3a21f72..77fc147 100644 --- a/app/src/utils/axiosHttp.ts +++ b/app/src/utils/axiosHttp.ts @@ -83,20 +83,18 @@ function toUrlencoded(params?: Record) { function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp { const _axios = axios.create(config); - // 添加响应拦截器处理401状态 - _axios.interceptors.response.use( - // 成功响应直接返回 - response => response, - // 错误响应处理 - error => { - // 检查是否是401未授权错误 - if (error.response && error.response.status === 401) { - // 调用removeAuthorization方法清除token - http.removeAuthorization(); - - // 这里可以添加额外的处理,比如跳转到登录页 - // 示例: window.location.href = '/login'; + // 添加请求拦截器,自动添加Authorization header + _axios.interceptors.request.use( + (config) => { + // 从Cookie中读取Authorization token + const token = Cookie.get(config.xsrfCookieName || 'Authorization'); + if (token && config.headers) { + // 将token添加到请求头 + config.headers[config.xsrfHeaderName || 'Authorization'] = token; } + return config; + }, + (error) => { return Promise.reject(error); } ); @@ -155,6 +153,24 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp { }, }; + // 添加响应拦截器处理401状态(在http对象创建后) + _axios.interceptors.response.use( + // 成功响应直接返回 + response => response, + // 错误响应处理 + error => { + // 检查是否是401未授权错误 + if (error.response && error.response.status === 401) { + // 清除token + http.removeAuthorization(); + + // 这里可以添加额外的处理,比如跳转到登录页 + // 示例: window.location.href = '/login'; + } + return Promise.reject(error); + } + ); + return http; } diff --git a/app/vite.config.ts b/app/vite.config.ts index 2056e57..a52b4fc 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -34,10 +34,10 @@ export default ({ command, mode }) => { open: false, //自动打开 proxy: { '/api': { - target: env.VITE_API_URL, + target: env.VITE_API_URL || 'http://localhost:10101', ws: true, changeOrigin: true, - rewrite: (path) => path.replace(/^\//, ''), + // 不重写路径,保持 /api 前缀 }, }, hmr: true, diff --git a/docs/scripts/check_database.sh b/docs/scripts/check_database.sh new file mode 100644 index 0000000..5826031 --- /dev/null +++ b/docs/scripts/check_database.sh @@ -0,0 +1,39 @@ +#!/bin/bash +DB_PATH="/Users/gaotu/WebstormProjects/dysync.net/db/dy.sqlite" + +echo "=== 数据库检查 ===" +echo "" + +echo "1. 视频记录总数:" +sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM dy_collect_video;" + +echo "" +echo "2. 有保存路径的视频数量:" +sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '';" + +echo "" +echo "3. Cookie配置数量:" +sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM dy_user_cookies;" + +echo "" +echo "4. Cookie配置详情:" +sqlite3 -header -column "$DB_PATH" "SELECT id, userName, savePath, favSavePath, uperSavePath, collHasSyncd FROM dy_user_cookies LIMIT 5;" + +echo "" +echo "5. 视频记录示例(前3条):" +sqlite3 -header -column "$DB_PATH" "SELECT id, videoTitle, author, videoSavePath, fileSize FROM dy_collect_video LIMIT 3;" + +echo "" +echo "6. 检查视频文件是否存在:" +sqlite3 "$DB_PATH" "SELECT videoSavePath FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '' LIMIT 1;" | while read path; do + if [ -n "$path" ]; then + echo "检查: $path" + if [ -f "$path" ]; then + echo "✅ 文件存在" + ls -lh "$path" | awk '{print " 大小: " $5}' + else + echo "❌ 文件不存在" + fi + fi +done + diff --git a/docs/scripts/安装ASP.NET运行时.sh b/docs/scripts/安装ASP.NET运行时.sh new file mode 100755 index 0000000..fe39a27 --- /dev/null +++ b/docs/scripts/安装ASP.NET运行时.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# 安装 ASP.NET Core 6.0 x64 Runtime + +echo "正在下载并安装 ASP.NET Core 6.0 x64 Runtime..." +echo "需要管理员权限,请输入您的密码:" + +curl -sSL https://dot.net/v1/dotnet-install.sh | sudo bash /dev/stdin --runtime aspnetcore --version 6.0.36 --arch x64 --install-dir /usr/local/share/dotnet/x64 + +echo "" +echo "安装完成!验证安装:" +export PATH="/usr/local/share/dotnet/x64:$PATH" +dotnet --list-runtimes + diff --git a/docs/scripts/安装x64运行时.sh b/docs/scripts/安装x64运行时.sh new file mode 100755 index 0000000..553f28d --- /dev/null +++ b/docs/scripts/安装x64运行时.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# 安装 .NET 6.0 x64 Runtime + +echo "正在下载并安装 .NET 6.0 x64 Runtime..." +echo "需要管理员权限,请输入您的密码:" + +curl -sSL https://dot.net/v1/dotnet-install.sh | sudo bash /dev/stdin --runtime dotnet --version 6.0.36 --arch x64 --install-dir /usr/local/share/dotnet/x64 + +echo "" +echo "安装完成!验证安装:" +export PATH="/usr/local/share/dotnet/x64:$PATH" +dotnet --list-runtimes + diff --git a/docs/scripts/检查数据库数据.py b/docs/scripts/检查数据库数据.py new file mode 100644 index 0000000..37d0ba4 --- /dev/null +++ b/docs/scripts/检查数据库数据.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""检查数据库中的视频数据""" + +import sqlite3 +import os +from pathlib import Path + +db_path = "/Users/gaotu/WebstormProjects/dysync.net/db/dy.sqlite" + +if not os.path.exists(db_path): + print(f"❌ 数据库文件不存在: {db_path}") + exit(1) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +print("=" * 60) +print("数据库检查报告") +print("=" * 60) + +# 1. 视频总数 +cursor.execute("SELECT COUNT(*) FROM dy_collect_video") +total = cursor.fetchone()[0] +print(f"\n1. 视频记录总数: {total}") + +# 2. 有保存路径的视频数量 +cursor.execute("SELECT COUNT(*) FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != ''") +with_path = cursor.fetchone()[0] +print(f"2. 有保存路径的视频数量: {with_path}") + +# 3. 按类型统计 +cursor.execute("SELECT ViedoType, COUNT(*) FROM dy_collect_video GROUP BY ViedoType") +type_stats = cursor.fetchall() +print(f"\n3. 按类型统计:") +for vtype, count in type_stats: + type_name = {"1": "喜欢的", "2": "收藏的", "3": "关注的"}.get(str(vtype), f"未知({vtype})") + print(f" {type_name}: {count}") + +# 4. 文件大小统计 +cursor.execute("SELECT SUM(fileSize) FROM dy_collect_video WHERE fileSize > 0") +total_size = cursor.fetchone()[0] or 0 +print(f"\n4. 总文件大小: {total_size / (1024**3):.2f} GB") + +# 5. 最近的视频记录 +print(f"\n5. 最近的视频记录(前5条):") +cursor.execute(""" + SELECT id, videoTitle, author, videoSavePath, fileSize, syncTime + FROM dy_collect_video + ORDER BY syncTime DESC + LIMIT 5 +""") +records = cursor.fetchall() +if records: + for idx, (vid, title, author, path, size, sync_time) in enumerate(records, 1): + title_short = (title[:30] + "...") if title and len(title) > 30 else (title or "无标题") + size_mb = (size / (1024**2)) if size else 0 + file_exists = "✅" if path and os.path.exists(path) else "❌" + print(f" {idx}. [{file_exists}] {title_short}") + print(f" 作者: {author}, 大小: {size_mb:.2f} MB") + if path: + print(f" 路径: {path}") +else: + print(" (无记录)") + +# 6. 检查文件是否存在 +print(f"\n6. 文件存在性检查:") +cursor.execute("SELECT videoSavePath FROM dy_collect_video WHERE videoSavePath IS NOT NULL AND videoSavePath != '' LIMIT 10") +paths = cursor.fetchall() +if paths: + exists_count = 0 + for (path,) in paths: + if path and os.path.exists(path): + exists_count += 1 + print(f" 检查了 {len(paths)} 个路径,{exists_count} 个文件存在") +else: + print(" (无路径记录)") + +conn.close() +print("\n" + "=" * 60) + diff --git a/dto/VideoPageRequestDTO.cs b/dto/VideoPageRequestDTO.cs index 43bc2a6..bf56f36 100644 --- a/dto/VideoPageRequestDTO.cs +++ b/dto/VideoPageRequestDTO.cs @@ -7,12 +7,30 @@ namespace dy.net.dto // 3. 引用类型(string)如果允许为null,显式声明为 string? public string? Tag { get; set; } + // 支持多个标签筛选 + public List? Tags { get; set; } + public string? Author { get; set; } public string? ViedoType { get; set; } // 4. 泛型集合(List)允许为null,声明为 List? public List? Dates { get; set; } + + /// + /// 删除状态筛选:null=全部,true=已删除(videoSavePath为空),false=未删除(videoSavePath不为空) + /// + public bool? IsDeleted { get; set; } + + /// + /// 排序字段:syncTime-同步时间, viedoType-同步类型, viedoCate-视频类型, author-作者, videoTitle-视频名称, dyUser-用户 + /// + public string? SortField { get; set; } + + /// + /// 排序方向:asc-升序, desc-降序 + /// + public string? SortOrder { get; set; } } diff --git a/extension/ServiceExtension.cs b/extension/ServiceExtension.cs index 6471d5e..9210aaa 100644 --- a/extension/ServiceExtension.cs +++ b/extension/ServiceExtension.cs @@ -95,8 +95,22 @@ namespace dy.net.extension { OnMessageReceived = context => { - context.Token = context.Request.Headers["Authorization"] + // 优先从 Header 读取 + var token = context.Request.Headers["Authorization"] .FirstOrDefault()?.Split(" ").Last(); + + // 如果 Header 中没有,尝试从 Cookie 读取 + if (string.IsNullOrEmpty(token)) + { + token = context.Request.Cookies["Authorization"]; + // Cookie 中可能包含 "Bearer " 前缀,需要移除 + if (!string.IsNullOrEmpty(token) && token.StartsWith("Bearer ")) + { + token = token.Substring(7); + } + } + + context.Token = token; return Task.CompletedTask; } }; diff --git a/job/DouYinCollectSyncJob.cs b/job/DouYinCollectSyncJob.cs index b0aa9ef..dc7b977 100644 --- a/job/DouYinCollectSyncJob.cs +++ b/job/DouYinCollectSyncJob.cs @@ -82,6 +82,7 @@ namespace dy.net.job while (hasMore) { + Serilog.Log.Debug($"collect-正在获取第 {index + 1} 页数据,cursor: {cursor}"); var data = await _douyinService.SyncCollectVideos(cursor, count, cookie.Cookies); hasMore = data != null && data.HasMore == 1 && cookie.CollHasSyncd == 0; if (cookie.CollHasSyncd == 1) @@ -94,11 +95,15 @@ namespace dy.net.job Serilog.Log.Debug($"collect-Cookie[{cookie.UserName}]获取收藏数据失败,请检查一下Cookie"); break; } + + Serilog.Log.Debug($"collect-API返回: HasMore={data.HasMore}, AwemeList数量={data.AwemeList?.Count ?? 0}"); + cursor = data != null && !string.IsNullOrWhiteSpace(data.Cursor) ? data.Cursor : "0"; if (data.AwemeList == null || !data.AwemeList.Any()) { + Serilog.Log.Debug($"collect-第 {index + 1} 页数据为空,结束同步"); break; } @@ -127,7 +132,27 @@ namespace dy.net.job var fileName = $"{item.AwemeId}.{v.Format}"; // 用ID做文件名,避免特殊字符 var savePath = Path.Combine(saveFolder, fileName); - if (!File.Exists(savePath)) + // 检查是否应该下载(根据配置) + var videoCreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime); + var downloadConfigService = new DownloadConfigService(commonService); + var shouldDownload = downloadConfigService.ShouldDownload("2", item.Author?.Uid, item.Author?.Nickname, videoCreateTime); + + // 获取作者头像信息(无论是否下载都需要) + var avatarImgName = $"{item.Author.Uid}.jpg"; + var avatarSavePath = Path.Combine(cookie.SavePath, "author", avatarImgName); + var avatarUrl = item.Author?.AvatarLarger?.UrlList != null && item.Author.AvatarLarger.UrlList.Any() + ? item.Author.AvatarLarger.UrlList[0] : null; + // 备用头像链接 + if (string.IsNullOrWhiteSpace(avatarUrl)) + { + avatarUrl = item.Author?.AvatarThumb?.UrlList != null && item.Author.AvatarThumb.UrlList.Any() + ? item.Author.AvatarThumb.UrlList[0] : null; + } + + // 下载视频和封面(如果配置允许) + string finalSavePath = null; + string finalCoverSavePath = null; + if (!File.Exists(savePath) && shouldDownload) { Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]开始下载"); await Task.Delay(_random.Next(1, 4) * 1000); @@ -135,87 +160,77 @@ namespace dy.net.job if (downVideo) { - Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}"); - } - else - { - Serilog.Log.Error($"collect-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}"); - - } - if (downVideo) - { + Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]下载成功"); await DownVideoCover(item, saveFolder, cookie.Cookies); - - // 用AuthorId做文件名,避免昵称特殊字符 - var avatarImgName = $"{item.Author.Uid}.jpg"; - var avatarSavePath = Path.Combine(cookie.SavePath, "author", avatarImgName); await DownAuthorAvatar(cookie.SavePath, item, avatarSavePath, cookie.Cookies); - var avatarUrl = item.Author?.AvatarLarger?.UrlList != null && item.Author.AvatarLarger.UrlList.Any() - ? item.Author.AvatarLarger.UrlList[0] : null; - // 备用头像链接 - if (string.IsNullOrWhiteSpace(avatarUrl)) - { - avatarUrl = item.Author?.AvatarThumb?.UrlList != null && item.Author.AvatarThumb.UrlList.Any() - ? item.Author.AvatarThumb.UrlList[0] : null; - } - - - - // 构造视频数据 - DyCollectVideo video = new() - { - ViedoType = "2", - AwemeId = item.AwemeId, - Author = item.Author?.Nickname, - AuthorId = item.Author?.Uid, - AuthorAvatar = avatarSavePath, - AuthorAvatarUrl = avatarUrl, - CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime), - VideoTitle = item.Desc, - Id = IdGener.GetLong().ToString(), - Resolution = $"{v.PlayAddr.Width}×{v.PlayAddr.Height}", - FileSize = v.PlayAddr.DataSize, - FileHash = v.PlayAddr.FileHash, - Tag1 = tag1, - Tag2 = tag2, - Tag3 = tag3, - VideoUrl = videoUrl, - VideoCoverUrl = item.Video.Cover.UrlList != null && item.Video.Cover.UrlList.Any() - ? item.Video.Cover.UrlList[0] : null, - VideoSavePath = savePath, - VideoCoverSavePath = Path.Combine(saveFolder, "poster.jpg"), - SyncTime = DateTime.Now, - DyUserId = data.Uid, - CookieId = cookie.Id - }; - videos.Add(video); + finalSavePath = savePath; + finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg"); var nfoPath = Path.Combine(saveFolder, $"{item.AwemeId}.nfo"); NfoFileGenerator.GenerateNfoFile(new VideoNfo { - Author = video.Author, + Author = item.Author?.Nickname, Poster = Path.Combine(saveFolder, "poster.jpg"), - Title = video.VideoTitle, + Title = item.Desc, Thumbnail = Path.Combine(saveFolder, "fanart.jpg"), - ReleaseDate = video.CreateTime, + ReleaseDate = videoCreateTime, Genres = new List { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList() }, nfoPath); - + } + else + { + Serilog.Log.Error($"collect-视频[{SanitizePath(item.Desc)}]下载失败"); } } - else + else if (!shouldDownload) { - //Serilog.Log.Debug($"视频[{item.AwemeId}]已存在,跳过下载"); + Serilog.Log.Debug($"collect-视频[{SanitizePath(item.Desc)}]不符合下载配置,仅同步信息到数据库"); } + else if (File.Exists(savePath)) + { + // 文件已存在,使用现有路径 + finalSavePath = savePath; + finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg"); + } + + // 无论是否下载,都保存视频信息到数据库 + DyCollectVideo video = new() + { + ViedoType = "2", + AwemeId = item.AwemeId, + Author = item.Author?.Nickname, + AuthorId = item.Author?.Uid, + AuthorAvatar = avatarSavePath, + AuthorAvatarUrl = avatarUrl, + CreateTime = videoCreateTime, + VideoTitle = item.Desc, + Id = IdGener.GetLong().ToString(), + Resolution = $"{v.PlayAddr.Width}×{v.PlayAddr.Height}", + FileSize = v.PlayAddr.DataSize, + FileHash = v.PlayAddr.FileHash, + Tag1 = tag1, + Tag2 = tag2, + Tag3 = tag3, + VideoUrl = videoUrl, + VideoCoverUrl = item.Video.Cover.UrlList != null && item.Video.Cover.UrlList.Any() + ? item.Video.Cover.UrlList[0] : null, + VideoSavePath = finalSavePath, // 如果未下载则为 null + VideoCoverSavePath = finalCoverSavePath, // 如果未下载则为 null + SyncTime = DateTime.Now, + DyUserId = data.Uid, + CookieId = cookie.Id + }; + videos.Add(video); } index++; // 批量保存到数据库(减少数据库操作频率) if (videos.Any()) { - //Serilog.Log.Debug($"处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条"); + Serilog.Log.Debug($"collect-处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条,准备保存到数据库"); try { - await _douyinVideoService.batchInsert(videos); + var insertResult = await _douyinVideoService.batchInsert(videos); + Serilog.Log.Debug($"collect-batchInsert返回结果: {insertResult}, 本次处理{videos.Count}条视频"); syncCount += videos.Count; } catch (Exception ex) diff --git a/job/DouYinFavoritSyncJob.cs b/job/DouYinFavoritSyncJob.cs index 17d55fb..95a328e 100644 --- a/job/DouYinFavoritSyncJob.cs +++ b/job/DouYinFavoritSyncJob.cs @@ -79,6 +79,7 @@ namespace dy.net.job string cursor = "0"; while (hasMore) { + Serilog.Log.Debug($"favorite-正在获取第 {index + 1} 页数据,cursor: {cursor}, secUserId: {cookie.SecUserId}"); var data = await _douyinService.SyncFavoriteVideos(count,cursor, cookie.SecUserId, cookie.Cookies); hasMore = data != null && data.HasMore == 1 && cookie.FavHasSyncd == 0; if (cookie.FavHasSyncd == 1) @@ -91,10 +92,14 @@ namespace dy.net.job Serilog.Log.Debug($"favorite-Cookie[{cookie.UserName}]获取喜欢的数据失败,请检查一下Cookie和sec_user_id"); break; } + + Serilog.Log.Debug($"favorite-API返回: HasMore={data.HasMore}, AwemeList数量={data.AwemeList?.Count ?? 0}"); + cursor = data != null && !string.IsNullOrWhiteSpace(data.MaxCursor) ? data.MaxCursor : "0"; if (data.AwemeList == null || !data.AwemeList.Any()) { + Serilog.Log.Debug($"favorite-第 {index + 1} 页数据为空,结束同步"); break; } @@ -122,7 +127,27 @@ namespace dy.net.job var fileName = $"{item.AwemeId}.{v.Format}"; // 用ID做文件名,避免特殊字符 var savePath = Path.Combine(saveFolder, fileName); - if (!File.Exists(savePath)) + // 检查是否应该下载(根据配置) + var videoCreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime); + var downloadConfigService = new DownloadConfigService(commonService); + var shouldDownload = downloadConfigService.ShouldDownload("1", item.Author?.Uid, item.Author?.Nickname, videoCreateTime); + + // 获取作者头像信息(无论是否下载都需要) + var avatarImgName = $"{item.Author.Uid}.jpg"; + var avatarSavePath = Path.Combine(cookie.FavSavePath, "author", avatarImgName); + var avatarUrl = item.Author?.AvatarLarger?.UrlList != null && item.Author.AvatarLarger.UrlList.Any() + ? item.Author.AvatarLarger.UrlList[0] : null; + // 尝试使用AvatarThumb作为备用头像URL + if (string.IsNullOrWhiteSpace(avatarUrl)) + { + avatarUrl = item.Author?.AvatarThumb?.UrlList != null && item.Author.AvatarThumb.UrlList.Any() + ? item.Author.AvatarThumb.UrlList[0] : null; + } + + // 下载视频和封面(如果配置允许) + string finalSavePath = null; + string finalCoverSavePath = null; + if (!File.Exists(savePath) && shouldDownload) { Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]开始下载"); await Task.Delay(_random.Next(1, 4) * 1000); @@ -130,86 +155,77 @@ namespace dy.net.job if (downVideo) { - Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}"); - } - else - { - Serilog.Log.Error($"favorite-视频[{SanitizePath(item.Desc)}]下载{(downVideo ? "成功" : "失败")}"); - } - if (downVideo) - { + Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]下载成功"); await DownVideoCover(item, saveFolder, cookie.Cookies); - - // 用AuthorId做文件名,避免昵称特殊字符 - var avatarImgName = $"{item.Author.Uid}.jpg"; - var avatarSavePath = Path.Combine(cookie.FavSavePath, "author", avatarImgName); await DownAuthorAvatar(cookie.FavSavePath, item, avatarSavePath, cookie.Cookies); - var avatarUrl = item.Author?.AvatarLarger?.UrlList != null && item.Author.AvatarLarger.UrlList.Any() - ? item.Author.AvatarLarger.UrlList[0] : null; - // 尝试使用AvatarThumb作为备用头像URL - if (string.IsNullOrWhiteSpace(avatarUrl)) - { - avatarUrl = item.Author?.AvatarThumb?.UrlList != null && item.Author.AvatarThumb.UrlList.Any() - ? item.Author.AvatarThumb.UrlList[0] : null; - } - - - - // 构造视频数据 - DyCollectVideo video = new() - { - ViedoType="1", - AwemeId = item.AwemeId, - Author = item.Author?.Nickname, - AuthorId = item.Author?.Uid, - AuthorAvatar = avatarSavePath, - AuthorAvatarUrl = avatarUrl, - CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime), - VideoTitle = item.Desc, - Id = IdGener.GetLong().ToString(), - Resolution = $"{v.PlayAddr.Width}×{v.PlayAddr.Height}", - FileSize = v.PlayAddr.DataSize, - FileHash = v.PlayAddr.FileHash, - Tag1 = tag1, - Tag2 = tag2, - Tag3 = tag3, - VideoUrl = videoUrl, - VideoCoverUrl = item.Video.Cover.UrlList != null && item.Video.Cover.UrlList.Any() - ? item.Video.Cover.UrlList[0] : null, - VideoSavePath = savePath, - VideoCoverSavePath = Path.Combine(saveFolder, "poster.jpg"), - SyncTime = DateTime.Now, - DyUserId = data.Uid, - CookieId = cookie.Id - }; - videos.Add(video); + finalSavePath = savePath; + finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg"); var nfoPath = Path.Combine(saveFolder, $"{item.AwemeId}.nfo"); NfoFileGenerator.GenerateNfoFile(new VideoNfo { - Author = video.Author, + Author = item.Author?.Nickname, Poster = Path.Combine(saveFolder, "poster.jpg"), - Title = video.VideoTitle, - Thumbnail= Path.Combine(saveFolder, "fanart.jpg"), - ReleaseDate = video.CreateTime, + Title = item.Desc, + Thumbnail = Path.Combine(saveFolder, "fanart.jpg"), + ReleaseDate = videoCreateTime, Genres = new List { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList() }, nfoPath); - + } + else + { + Serilog.Log.Error($"favorite-视频[{SanitizePath(item.Desc)}]下载失败"); } } - else + else if (!shouldDownload) { - //Serilog.Log.Debug($"视频[{item.AwemeId}]已存在,跳过下载"); + Serilog.Log.Debug($"favorite-视频[{SanitizePath(item.Desc)}]不符合下载配置,仅同步信息到数据库"); } + else if (File.Exists(savePath)) + { + // 文件已存在,使用现有路径 + finalSavePath = savePath; + finalCoverSavePath = Path.Combine(saveFolder, "poster.jpg"); + } + + // 无论是否下载,都保存视频信息到数据库 + DyCollectVideo video = new() + { + ViedoType = "1", + AwemeId = item.AwemeId, + Author = item.Author?.Nickname, + AuthorId = item.Author?.Uid, + AuthorAvatar = avatarSavePath, + AuthorAvatarUrl = avatarUrl, + CreateTime = videoCreateTime, + VideoTitle = item.Desc, + Id = IdGener.GetLong().ToString(), + Resolution = $"{v.PlayAddr.Width}×{v.PlayAddr.Height}", + FileSize = v.PlayAddr.DataSize, + FileHash = v.PlayAddr.FileHash, + Tag1 = tag1, + Tag2 = tag2, + Tag3 = tag3, + VideoUrl = videoUrl, + VideoCoverUrl = item.Video.Cover.UrlList != null && item.Video.Cover.UrlList.Any() + ? item.Video.Cover.UrlList[0] : null, + VideoSavePath = finalSavePath, // 如果未下载则为 null + VideoCoverSavePath = finalCoverSavePath, // 如果未下载则为 null + SyncTime = DateTime.Now, + DyUserId = data.Uid, + CookieId = cookie.Id + }; + videos.Add(video); } index++; // 批量保存到数据库(减少数据库操作频率) if (videos.Any()) { - //Serilog.Log.Debug($"处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条"); + Serilog.Log.Debug($"favorite-处理Cookie[{cookie.UserName}]的第{index}页数据,共{videos.Count}条,准备保存到数据库"); try { - await _douyinVideoService.batchInsert(videos); + var insertResult = await _douyinVideoService.batchInsert(videos); + Serilog.Log.Debug($"favorite-batchInsert返回结果: {insertResult}, 本次处理{videos.Count}条视频"); syncCount += videos.Count; } catch (Exception ex) diff --git a/model/AppConfig.cs b/model/AppConfig.cs index 84abf55..838ff09 100644 --- a/model/AppConfig.cs +++ b/model/AppConfig.cs @@ -20,5 +20,41 @@ namespace dy.net.model /// 每次查询数量 /// public int BatchCount { get; set; } = 10; + + /// + /// 是否启用自动下载(默认false,不自动下载) + /// + [SugarColumn(IsNullable = true)] + public bool? EnableAutoDownload { get; set; } = false; + + /// + /// 下载时间范围(天数,例如:30表示只下载最近30天的视频,0表示不限制) + /// + [SugarColumn(IsNullable = true)] + public int? DownloadDaysRange { get; set; } = 0; + + /// + /// 作者白名单(JSON数组,例如:["作者1","作者2"],为空则不限制) + /// + [SugarColumn(Length = 2000, IsNullable = true)] + public string AuthorWhitelist { get; set; } + + /// + /// 作者黑名单(JSON数组,例如:["作者1","作者2"],为空则不限制) + /// + [SugarColumn(Length = 2000, IsNullable = true)] + public string AuthorBlacklist { get; set; } + + /// + /// 是否下载喜欢的视频(默认false) + /// + [SugarColumn(IsNullable = true)] + public bool? DownloadFavorite { get; set; } = false; + + /// + /// 是否下载收藏的视频(默认false) + /// + [SugarColumn(IsNullable = true)] + public bool? DownloadCollect { get; set; } = false; } } diff --git a/repository/DyCollectVideoRepository.cs b/repository/DyCollectVideoRepository.cs index 9fa0f46..ec34788 100644 --- a/repository/DyCollectVideoRepository.cs +++ b/repository/DyCollectVideoRepository.cs @@ -1,5 +1,6 @@ using dy.net.model; using SqlSugar; +using System.Linq.Expressions; namespace dy.net.repository { @@ -17,10 +18,14 @@ namespace dy.net.repository /// /// 页码(从1开始) /// 每页数量 - /// 可选标签过滤 + /// 可选标签过滤(单个,兼容旧版本) + /// 可选标签过滤(多个) /// 可选作者过滤 + /// 删除状态筛选:null=全部,true=已删除(videoSavePath为空),false=未删除(videoSavePath不为空) + /// 排序字段:syncTime-同步时间, viedoType-同步类型, viedoCate-视频类型, author-作者, videoTitle-视频名称, dyUser-用户 + /// 排序方向:asc-升序, desc-降序 /// 分页结果(视频列表和总数) - public async Task<(List list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, string author = null,string viedoType=null, List dates = null) + public async Task<(List list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, List tags = null, string author = null,string viedoType=null, List dates = null, bool? isDeleted = null, string sortField = null, string sortOrder = null) { DateTime? start = null; @@ -35,16 +40,206 @@ namespace dy.net.repository start = Convert.ToDateTime(dates[0]); } - var where = this.Db.Queryable() - .WhereIF(!string.IsNullOrWhiteSpace(tag), x => x.Tag1 == tag) + var where = this.Db.Queryable(); + + // 支持多个标签筛选(优先使用 tags,如果为空则使用 tag) + // 注意:tags 匹配的是 viedoCate(Tag1/Tag2 组合),格式如 "美食/美食展示" + // 特殊值 "未分类" 表示 Tag1 和 Tag2 都为空的视频 + if (tags != null && tags.Any()) + { + // 过滤空值 + var validTags = tags.Where(t => !string.IsNullOrWhiteSpace(t)).ToList(); + if (validTags.Any()) + { + // 检查是否包含"未分类"选项 + var hasEmptyCategory = validTags.Contains("未分类"); + var otherTags = validTags.Where(t => t != "未分类").ToList(); + + // 构建 OR 条件:对于每个 tag,匹配对应的 Tag1/Tag2 组合 + if (hasEmptyCategory && otherTags.Any()) + { + // 同时包含"未分类"和其他类型 + // 使用内存过滤的方式,确保逻辑正确 + var matchingVideoIds = new List(); + + // 获取所有视频(先应用其他筛选条件) + var allVideos = await this.Db.Queryable() + .WhereIF(!string.IsNullOrWhiteSpace(author), x => x.Author == author) + .WhereIF(start.HasValue, x => x.SyncTime >= start.Value) + .WhereIF(end.HasValue, x => x.SyncTime <= end.Value) + .WhereIF(!string.IsNullOrWhiteSpace(viedoType) && viedoType != "*", x => x.ViedoType == viedoType) + .WhereIF(isDeleted.HasValue && isDeleted.Value, x => string.IsNullOrWhiteSpace(x.VideoSavePath)) + .WhereIF(isDeleted.HasValue && !isDeleted.Value, x => !string.IsNullOrWhiteSpace(x.VideoSavePath)) + .ToListAsync(); + + // 在内存中过滤 viedoCate + foreach (var video in allVideos) + { + var viedoCate = (string.IsNullOrWhiteSpace(video.Tag1) ? "" : video.Tag1) + "/" + (string.IsNullOrWhiteSpace(video.Tag2) ? "" : video.Tag2); + + // 检查是否是"未分类"或匹配其他标签 + bool isMatch = (string.IsNullOrWhiteSpace(video.Tag1) && string.IsNullOrWhiteSpace(video.Tag2)) || // 未分类 + otherTags.Contains(viedoCate); // 其他类型 + + if (isMatch) + { + matchingVideoIds.Add(video.Id); + } + } + + if (matchingVideoIds.Any()) + { + // 使用 ID 列表重新构建查询 + where = this.Db.Queryable() + .Where(x => matchingVideoIds.Contains(x.Id)); + } + else + { + // 如果没有匹配的数据,返回空结果 + where = this.Db.Queryable().Where(x => false); + } + } + else if (hasEmptyCategory) + { + // 只选择了"未分类" + where = where.Where(x => string.IsNullOrWhiteSpace(x.Tag1) && string.IsNullOrWhiteSpace(x.Tag2)); + } + else + { + // 只选择了其他类型,构建所有条件的 OR + // 使用更简单的方法:先获取所有符合条件的视频 ID,然后使用 In 方法 + // 这样可以避免复杂的表达式树构建 + + var matchingVideoIds = new List(); + + // 获取所有视频 + var allVideos = await this.Db.Queryable() + .WhereIF(!string.IsNullOrWhiteSpace(author), x => x.Author == author) + .WhereIF(start.HasValue, x => x.SyncTime >= start.Value) + .WhereIF(end.HasValue, x => x.SyncTime <= end.Value) + .WhereIF(!string.IsNullOrWhiteSpace(viedoType) && viedoType != "*", x => x.ViedoType == viedoType) + .WhereIF(isDeleted.HasValue && isDeleted.Value, x => string.IsNullOrWhiteSpace(x.VideoSavePath)) + .WhereIF(isDeleted.HasValue && !isDeleted.Value, x => !string.IsNullOrWhiteSpace(x.VideoSavePath)) + .ToListAsync(); + + // 在内存中过滤 viedoCate + foreach (var video in allVideos) + { + var viedoCate = (string.IsNullOrWhiteSpace(video.Tag1) ? "" : video.Tag1) + "/" + (string.IsNullOrWhiteSpace(video.Tag2) ? "" : video.Tag2); + + if (otherTags.Contains(viedoCate)) + { + matchingVideoIds.Add(video.Id); + } + } + + if (matchingVideoIds.Any()) + { + // 使用 ID 列表重新构建查询 + where = this.Db.Queryable() + .Where(x => matchingVideoIds.Contains(x.Id)); + } + else + { + // 如果没有匹配的数据,返回空结果 + where = this.Db.Queryable().Where(x => false); + } + } + } + } + else if (!string.IsNullOrWhiteSpace(tag)) + { + // 兼容旧版本的单标签筛选 + if (tag.Contains("/")) + { + var parts = tag.Split('/', 2); + var tag1 = parts[0]; + var tag2 = parts.Length > 1 ? parts[1] : ""; + where = where.Where(x => x.Tag1 == tag1 && (string.IsNullOrWhiteSpace(tag2) || x.Tag2 == tag2 || string.IsNullOrWhiteSpace(x.Tag2))); + } + else + { + where = where.Where(x => x.Tag1 == tag); + } + } + + // 应用其他筛选条件 + where = where .WhereIF(!string.IsNullOrWhiteSpace(author), x => x.Author == author) .WhereIF(start.HasValue, x => x.SyncTime >= start.Value) .WhereIF(end.HasValue, x => x.SyncTime <= end.Value) - .WhereIF(!string.IsNullOrWhiteSpace(viedoType) && viedoType != "*", x => x.ViedoType == viedoType); + .WhereIF(!string.IsNullOrWhiteSpace(viedoType) && viedoType != "*", x => x.ViedoType == viedoType) + .WhereIF(isDeleted.HasValue && isDeleted.Value, x => string.IsNullOrWhiteSpace(x.VideoSavePath)) // 已删除:videoSavePath为空 + .WhereIF(isDeleted.HasValue && !isDeleted.Value, x => !string.IsNullOrWhiteSpace(x.VideoSavePath)); // 未删除:videoSavePath不为空 var totalCount = await where.CountAsync(); - var list = await where.OrderByDescending(x=>x.SyncTime).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync(); + + // 构建排序逻辑 + var isAsc = !string.IsNullOrWhiteSpace(sortOrder) && sortOrder.ToLower() == "asc"; + var query = where; + + // 调试日志 + Console.WriteLine($"Repository排序参数 - sortField: {sortField}, sortOrder: {sortOrder}, isAsc: {isAsc}"); + + if (!string.IsNullOrWhiteSpace(sortField)) + { + switch (sortField.ToLower()) + { + case "synctime": + query = isAsc ? query.OrderBy(x => x.SyncTime) : query.OrderByDescending(x => x.SyncTime); + Console.WriteLine($"应用排序: SyncTime {(isAsc ? "升序" : "降序")}"); + break; + case "viedotype": + query = isAsc ? query.OrderBy(x => x.ViedoType) : query.OrderByDescending(x => x.ViedoType); + Console.WriteLine($"应用排序: ViedoType {(isAsc ? "升序" : "降序")}"); + break; + case "viedocate": + // 视频类型需要按Tag1和Tag2排序,SqlSugar使用OrderBy链式调用 + if (isAsc) + { + query = query.OrderBy(x => x.Tag1).OrderBy(x => x.Tag2); + } + else + { + query = query.OrderByDescending(x => x.Tag1).OrderByDescending(x => x.Tag2); + } + Console.WriteLine($"应用排序: ViedoCate {(isAsc ? "升序" : "降序")}"); + break; + case "author": + query = isAsc ? query.OrderBy(x => x.Author) : query.OrderByDescending(x => x.Author); + Console.WriteLine($"应用排序: Author {(isAsc ? "升序" : "降序")}"); + break; + case "videotitle": + query = isAsc ? query.OrderBy(x => x.VideoTitle) : query.OrderByDescending(x => x.VideoTitle); + Console.WriteLine($"应用排序: VideoTitle {(isAsc ? "升序" : "降序")}"); + break; + case "dyuser": + // 用户字段需要先查询用户信息,这里先按CookieId排序,后续会设置DyUser + query = isAsc ? query.OrderBy(x => x.CookieId) : query.OrderByDescending(x => x.CookieId); + Console.WriteLine($"应用排序: DyUser {(isAsc ? "升序" : "降序")}"); + break; + default: + // 默认按同步时间降序 + query = query.OrderByDescending(x => x.SyncTime); + Console.WriteLine($"未识别的排序字段,使用默认排序: SyncTime 降序"); + break; + } + } + else + { + // 默认按同步时间降序 + query = query.OrderByDescending(x => x.SyncTime); + Console.WriteLine("无排序字段,使用默认排序: SyncTime 降序"); + } + + var list = await query.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync(); + Console.WriteLine($"查询完成,返回 {list.Count} 条记录"); + if (list.Any()) + { + Console.WriteLine($"第一条记录的同步时间: {list.First().SyncTime}"); + Console.WriteLine($"最后一条记录的同步时间: {list.Last().SyncTime}"); + } if (list.Any()) { var users= await this.Db.Queryable().ToListAsync(); foreach (var item in list) diff --git a/service/CommonService.cs b/service/CommonService.cs index 74be584..1f7eec5 100644 --- a/service/CommonService.cs +++ b/service/CommonService.cs @@ -54,6 +54,13 @@ namespace dy.net.service { cc.Cron = config.Cron; cc.BatchCount = config.BatchCount; + // 更新下载配置 + cc.EnableAutoDownload = config.EnableAutoDownload; + cc.DownloadDaysRange = config.DownloadDaysRange; + cc.AuthorWhitelist = config.AuthorWhitelist; + cc.AuthorBlacklist = config.AuthorBlacklist; + cc.DownloadFavorite = config.DownloadFavorite; + cc.DownloadCollect = config.DownloadCollect; } var update = await sqlSugarClient.Updateable(cc).ExecuteCommandAsync(); diff --git a/service/DownloadConfigService.cs b/service/DownloadConfigService.cs new file mode 100644 index 0000000..3324543 --- /dev/null +++ b/service/DownloadConfigService.cs @@ -0,0 +1,124 @@ +using dy.net.model; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace dy.net.service +{ + /// + /// 下载配置服务,用于判断是否应该下载视频 + /// + public class DownloadConfigService + { + private readonly CommonService _commonService; + + public DownloadConfigService(CommonService commonService) + { + _commonService = commonService; + } + + private AppConfig GetConfig() + { + return _commonService.GetConfig(); + } + + /// + /// 判断是否应该下载视频 + /// + /// 视频类型:"1"=喜欢,"2"=收藏 + /// 作者ID + /// 作者名称 + /// 视频创建时间 + /// true=应该下载,false=不应该下载 + public bool ShouldDownload(string videoType, string authorId, string authorName, DateTime createTime) + { + var config = GetConfig(); + if (config == null) + { + return false; + } + + // 如果未启用自动下载,直接返回false + if (config.EnableAutoDownload != true) + { + return false; + } + + // 检查视频类型是否允许下载 + if (videoType == "1" && config.DownloadFavorite != true) + { + return false; // 喜欢类型但未启用下载 + } + + if (videoType == "2" && config.DownloadCollect != true) + { + return false; // 收藏类型但未启用下载 + } + + // 检查时间范围 + if (config.DownloadDaysRange.HasValue && config.DownloadDaysRange.Value > 0) + { + var daysDiff = (DateTime.Now - createTime).TotalDays; + if (daysDiff > config.DownloadDaysRange.Value) + { + return false; // 超出时间范围 + } + } + + // 检查作者白名单 + if (!string.IsNullOrWhiteSpace(config.AuthorWhitelist)) + { + try + { + var whitelist = JsonConvert.DeserializeObject>(config.AuthorWhitelist); + if (whitelist != null && whitelist.Any()) + { + // 如果配置了白名单,只有白名单中的作者才下载 + var isInWhitelist = whitelist.Any(a => + (!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase)) + ); + if (!isInWhitelist) + { + return false; // 不在白名单中 + } + } + } + catch (Exception ex) + { + Serilog.Log.Warning($"解析作者白名单失败:{ex.Message}"); + } + } + + // 检查作者黑名单 + if (!string.IsNullOrWhiteSpace(config.AuthorBlacklist)) + { + try + { + var blacklist = JsonConvert.DeserializeObject>(config.AuthorBlacklist); + if (blacklist != null && blacklist.Any()) + { + // 如果配置了黑名单,黑名单中的作者不下载 + var isInBlacklist = blacklist.Any(a => + (!string.IsNullOrWhiteSpace(authorId) && a.Equals(authorId, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrWhiteSpace(authorName) && a.Equals(authorName, StringComparison.OrdinalIgnoreCase)) + ); + if (isInBlacklist) + { + return false; // 在黑名单中 + } + } + } + catch (Exception ex) + { + Serilog.Log.Warning($"解析作者黑名单失败:{ex.Message}"); + } + } + + // 所有检查都通过,可以下载 + return true; + } + } +} + diff --git a/service/DyCollectVideoService.cs b/service/DyCollectVideoService.cs index 1d042b7..5f9f641 100644 --- a/service/DyCollectVideoService.cs +++ b/service/DyCollectVideoService.cs @@ -41,12 +41,18 @@ namespace dy.net.service .Where(video => !existingAwemeIds.Contains(video.AwemeId)) .ToList(); + Serilog.Log.Debug($"batchInsert: 待插入{videos.Count}条,已存在{existingAwemeIds.Count}条,需要插入{videosToInsert.Count}条"); + // 4. 如果没有需要插入的新记录,直接返回成功 if (!videosToInsert.Any()) + { + Serilog.Log.Debug($"batchInsert: 所有视频都已存在,无需插入"); return true; + } // 5. 批量插入过滤后的新记录 var insertedCount = await _dyCollectVideoRepository.InsertRangeAsync(videosToInsert); + Serilog.Log.Debug($"batchInsert: 实际插入{insertedCount}条记录"); // 返回是否插入成功(至少插入一条) return insertedCount > 0; @@ -55,16 +61,47 @@ namespace dy.net.service public async Task GetStatics() { - - List list = await this._dyCollectVideoRepository.GetAllAsync(); + List allList = await this._dyCollectVideoRepository.GetAllAsync(); + if (!allList.Any()) + return new VideoStaticsDto(); + + // 只统计文件实际存在的视频 + var list = allList.Where(x => + !string.IsNullOrWhiteSpace(x.VideoSavePath) && + System.IO.File.Exists(x.VideoSavePath) + ).ToList(); + + Serilog.Log.Debug($"统计视频:数据库记录 {allList.Count} 条,文件存在 {list.Count} 条"); + if (!list.Any()) return new VideoStaticsDto(); - var Categories = list.GroupBy(x => x.Tag1).Select(x => new VideoStaticsItemDto { Name = x.Key, Count = x.LongCount() }).OrderByDescending(p => p.Count).ToList(); - Categories.Where(x => string.IsNullOrWhiteSpace(x.Name)).ToList().ForEach(x => x.Name = "其他"); + + // 按 viedoCate(Tag1/Tag2 组合)分组统计分类 + var Categories = list + .GroupBy(x => + { + // 使用与模型 ViedoCate 属性相同的逻辑:Tag1 + "/" + Tag2 + var tag1 = string.IsNullOrWhiteSpace(x.Tag1) ? "" : x.Tag1; + var tag2 = string.IsNullOrWhiteSpace(x.Tag2) ? "" : x.Tag2; + var viedoCate = tag1 + "/" + tag2; + // 如果 Tag1 和 Tag2 都为空,标记为"未分类" + if (string.IsNullOrWhiteSpace(tag1) && string.IsNullOrWhiteSpace(tag2)) + { + return "未分类"; + } + return viedoCate; + }) + .Select(x => new VideoStaticsItemDto + { + Name = x.Key, + Count = x.LongCount() + }) + .OrderByDescending(p => p.Count) + .ToList(); var data = new VideoStaticsDto { AuthorCount = list.Select(x => x.AuthorId).Distinct().Count(), - CategoryCount = list.Select(x => x.Tag1).Distinct().Count(), + CategoryCount = Categories.Count, // 使用实际分类数量 VideoCount = list.Count, Categories = Categories, FavoriteCount = list.Count(x => x.ViedoType == "1"), @@ -85,9 +122,154 @@ namespace dy.net.service //分页查询 - public async Task<(List list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, string author = null, string viedoType = null, List? dates = null) + public async Task<(List list, int totalCount)> GetPagedAsync(int pageIndex, int pageSize, string tag = null, List tags = null, string author = null, string viedoType = null, List? dates = null, bool? isDeleted = null, string sortField = null, string sortOrder = null) { - return await _dyCollectVideoRepository.GetPagedAsync(pageIndex, pageSize, tag, author, viedoType, dates); + return await _dyCollectVideoRepository.GetPagedAsync(pageIndex, pageSize, tag, tags, author, viedoType, dates, isDeleted, sortField, sortOrder); + } + + /// + /// 获取所有视频类型列表(用于下拉框) + /// + /// + public async Task> GetAllCategoriesAsync() + { + try + { + // 获取所有视频记录 + var allVideos = await _dyCollectVideoRepository.GetAllAsync(); + Serilog.Log.Debug($"获取视频类型列表:共 {allVideos?.Count ?? 0} 条视频记录"); + + // 提取所有非空的 viedoCate(Tag1/Tag2 组合),去重并排序 + // 使用与模型 ViedoCate 属性相同的逻辑:Tag1 + "/" + Tag2 + var categories = allVideos + .Select(x => + { + // 使用与模型 ViedoCate 属性相同的逻辑 + var tag1 = string.IsNullOrWhiteSpace(x.Tag1) ? "" : x.Tag1; + var tag2 = string.IsNullOrWhiteSpace(x.Tag2) ? "" : x.Tag2; + var viedoCate = tag1 + "/" + tag2; + return viedoCate; + }) + .Where(x => !string.IsNullOrWhiteSpace(x) && x != "/") // 过滤空值和只有 "/" 的情况 + .Distinct() + .OrderBy(x => x) + .ToList(); + + // 检查是否存在空类型的视频(Tag1 和 Tag2 都为空) + var hasEmptyCategory = allVideos.Any(x => string.IsNullOrWhiteSpace(x.Tag1) && string.IsNullOrWhiteSpace(x.Tag2)); + if (hasEmptyCategory) + { + // 在列表开头添加"未分类"选项 + categories.Insert(0, "未分类"); + } + + Serilog.Log.Debug($"提取到的视频类型数量:{categories.Count},类型列表:{string.Join(", ", categories)}"); + return categories; + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "获取视频类型列表失败"); + return new List(); + } + } + + /// + /// 根据ID获取视频信息 + /// + /// 视频ID + /// + public async Task GetByIdAsync(string id) + { + return await _dyCollectVideoRepository.GetByIdAsync(id); + } + + /// + /// 清理数据库中文件不存在的视频记录 + /// + /// 清理的记录数量 + public async Task CleanupMissingFilesAsync() + { + try + { + var allVideos = await _dyCollectVideoRepository.GetAllAsync(); + if (!allVideos.Any()) + return 0; + + // 找出需要清理的视频ID: + // 1. VideoSavePath 不为空但文件不存在的记录 + // 2. VideoSavePath 为空或null的记录(这些记录没有视频文件,也应该被清理) + var missingFileIds = allVideos + .Where(x => + // 情况1:有路径但文件不存在 + (!string.IsNullOrWhiteSpace(x.VideoSavePath) && !System.IO.File.Exists(x.VideoSavePath)) || + // 情况2:路径为空(这些记录没有视频文件,也应该清理) + string.IsNullOrWhiteSpace(x.VideoSavePath) + ) + .Select(x => x.Id) + .ToList(); + + if (!missingFileIds.Any()) + { + Serilog.Log.Debug("清理检查:所有视频文件都存在,无需清理"); + return 0; + } + + var pathEmptyCount = allVideos.Count(x => string.IsNullOrWhiteSpace(x.VideoSavePath)); + var fileMissingCount = allVideos.Count(x => !string.IsNullOrWhiteSpace(x.VideoSavePath) && !System.IO.File.Exists(x.VideoSavePath)); + + Serilog.Log.Debug($"清理检查:发现 {missingFileIds.Count} 条需要清理的记录(路径为空:{pathEmptyCount} 条,文件不存在:{fileMissingCount} 条)"); + + // 批量删除 + var deletedCount = await _dyCollectVideoRepository.DeleteByIdsAsync(missingFileIds); + Serilog.Log.Debug($"清理完成:删除了 {deletedCount} 条记录"); + + return deletedCount; + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "清理文件不存在的记录失败"); + throw; + } + } + + /// + /// 删除指定作者的所有视频数据 + /// + /// 作者名称 + /// 删除的记录数量 + public async Task DeleteAuthorVideosAsync(string authorName) + { + try + { + var allVideos = await _dyCollectVideoRepository.GetAllAsync(); + if (!allVideos.Any()) + return 0; + + // 找出该作者的所有视频ID + var authorVideoIds = allVideos + .Where(x => !string.IsNullOrWhiteSpace(x.Author) && x.Author.Equals(authorName, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Id) + .ToList(); + + if (!authorVideoIds.Any()) + { + Serilog.Log.Debug($"删除作者视频:未找到作者 {authorName} 的视频记录"); + return 0; + } + + Serilog.Log.Debug($"删除作者视频:发现作者 {authorName} 的 {authorVideoIds.Count} 条视频记录"); + + // 批量删除 + var deletedCount = await _dyCollectVideoRepository.DeleteByIdsAsync(authorVideoIds); + Serilog.Log.Debug($"删除完成:删除了 {deletedCount} 条记录"); + + return deletedCount; + } + catch (Exception ex) + { + Serilog.Log.Error(ex, $"删除作者 {authorName} 的视频失败"); + throw; + } } } diff --git a/service/DyHttpClientService.cs b/service/DyHttpClientService.cs index d77d678..df98082 100644 --- a/service/DyHttpClientService.cs +++ b/service/DyHttpClientService.cs @@ -68,11 +68,24 @@ namespace dy.net.service if (respose.IsSuccessStatusCode) { var data = await respose.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(data); + Serilog.Log.Debug($"SyncCollectVideos API响应长度: {data?.Length ?? 0}"); + var result = JsonConvert.DeserializeObject(data); + if (result != null) + { + Serilog.Log.Debug($"SyncCollectVideos 解析结果: HasMore={result.HasMore}, AwemeList数量={result.AwemeList?.Count ?? 0}"); + } + else + { + var preview = data != null && data.Length > 0 ? data.Substring(0, Math.Min(500, data.Length)) : "空响应"; + Serilog.Log.Warning($"SyncCollectVideos 解析失败,响应内容前500字符: {preview}"); + } + return result; } else { - Serilog.Log.Error($"SyncCollectVideos fail: {respose.StatusCode}"); + var errorContent = await respose.Content.ReadAsStringAsync(); + var errorPreview = errorContent != null && errorContent.Length > 0 ? errorContent.Substring(0, Math.Min(500, errorContent.Length)) : "空响应"; + Serilog.Log.Error($"SyncCollectVideos fail: {respose.StatusCode}, 响应内容: {errorPreview}"); return null; } } @@ -129,15 +142,29 @@ namespace dy.net.service // 构建请求URL var queryString = new FormUrlEncodedContent(dics); string fullUrl = $"{DouYinApi}/favorite?{await queryString.ReadAsStringAsync()}"; + Serilog.Log.Debug($"SyncFavoriteVideos 请求URL: {fullUrl.Substring(0, Math.Min(200, fullUrl.Length))}..."); var respose = await httpClient.GetAsync(fullUrl); if (respose.IsSuccessStatusCode) { var data = await respose.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(data); + Serilog.Log.Debug($"SyncFavoriteVideos API响应长度: {data?.Length ?? 0}"); + var result = JsonConvert.DeserializeObject(data); + if (result != null) + { + Serilog.Log.Debug($"SyncFavoriteVideos 解析结果: HasMore={result.HasMore}, AwemeList数量={result.AwemeList?.Count ?? 0}"); + } + else + { + var preview = data != null && data.Length > 0 ? data.Substring(0, Math.Min(500, data.Length)) : "空响应"; + Serilog.Log.Warning($"SyncFavoriteVideos 解析失败,响应内容前500字符: {preview}"); + } + return result; } else { - Serilog.Log.Error($"SyncFavoriteVideos fail: {respose.StatusCode}"); + var errorContent = await respose.Content.ReadAsStringAsync(); + var errorPreview = errorContent != null && errorContent.Length > 0 ? errorContent.Substring(0, Math.Min(500, errorContent.Length)) : "空响应"; + Serilog.Log.Error($"SyncFavoriteVideos fail: {respose.StatusCode}, 响应内容: {errorPreview}"); return null; } }
+ 是否删除该作者「{{ selectedAuthorForDelete?.name }}」的数据? +
+ 您可以选择以下操作: +