diff --git a/Controllers/ConfigController.cs b/Controllers/ConfigController.cs index 98908cb..e04a002 100644 --- a/Controllers/ConfigController.cs +++ b/Controllers/ConfigController.cs @@ -28,6 +28,18 @@ namespace dy.net.Controllers private readonly DouyinFollowService douyinFollowService; private readonly DouyinCookieService douyinCookieService; + // 定义允许上传的音频扩展名(小写) + private readonly string[] _allowedAudioExtensions = { ".mp3", ".wav" }; + + // 定义允许的音频 MIME 类型(增强验证) + private readonly string[] _allowedAudioMimeTypes = { + "audio/mpeg", "audio/wav" + }; + + // 最大文件大小:20MB(可根据需求调整) + private const long _maxFileSize = 20 * 1024 * 1024; + + public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService) { this.dyCookieService = dyCookieService; @@ -374,5 +386,108 @@ namespace dy.net.Controllers return ApiResult.Fail("请求失败"); } } + + + /// + /// 上传音频文件接口 + /// + /// 要上传的音频文件 + /// 上传结果 + [HttpPost("UploadAudio")] + public IActionResult UploadAudio(IFormFile file) + { + // 1. 验证文件是否为空 + if (file == null || file.Length == 0) + { + return BadRequest(new { success = false, message = "请选择要上传的音频文件" }); + } + + try + { + // 2. 验证文件大小 + if (file.Length > _maxFileSize) + { + return BadRequest(new { success = false, message = $"文件大小超过限制(最大允许 {_maxFileSize / 1024 / 1024}MB)" }); + } + + // 3. 获取文件扩展名并验证 + var fileExtension = Path.GetExtension(file.FileName).ToLower(); + if (!_allowedAudioExtensions.Contains(fileExtension)) + { + return BadRequest(new + { + success = false, + message = $"不支持的音频格式,仅允许:{string.Join(", ", _allowedAudioExtensions)}" + }); + } + + // 4. 验证 MIME 类型(可选但推荐,防止扩展名伪造) + var contentType = file.ContentType.ToLower(); + if (!_allowedAudioMimeTypes.Contains(contentType)) + { + return BadRequest(new + { + success = false, + message = "文件类型验证失败,请上传合法的音频文件" + }); + } + + //先删除 + var uploadMp3 = Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "mp3")) + .Where(filePath => Path.GetFileNameWithoutExtension(filePath) != "silent_10") + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(uploadMp3) && System.IO.File.Exists(uploadMp3)) + { + System.IO.File.Delete(uploadMp3); + } + + + // 5. 生成唯一文件名(避免重复) + var uniqueFileName = $"dysync_default{fileExtension}"; + + // 6. 定义文件保存路径(建议配置在 appsettings.json 中,这里简化处理) + var uploadPath = Path.Combine(AppContext.BaseDirectory, "mp3"); + + // 确保目录存在 + if (!Directory.Exists(uploadPath)) + { + Directory.CreateDirectory(uploadPath); + } + + // 7. 保存文件 + var filePath = Path.Combine(uploadPath, uniqueFileName); + + if (System.IO.File.Exists(filePath)) + { + System.IO.File.Delete(filePath); + } + using (var stream = new FileStream(filePath, FileMode.Create)) + { + file.CopyTo(stream); + } + + // 8. 返回成功结果(可根据需求返回文件路径/URL 等) + return ApiResult.Success(new { fileName = uniqueFileName, filePath }); + } + catch (Exception ex) + { + // 捕获异常并返回错误信息 + return ApiResult.Fail(ex.Message); + } + } + [AllowAnonymous] + [HttpGet("defaudio")] + public async Task GetDefaultAudioUrl() + { + var uploadMp3 = Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "mp3")) + .Where(filePath => Path.GetFileNameWithoutExtension(filePath) != "silent_10") + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(uploadMp3) && System.IO.File.Exists(uploadMp3)) + { + return File(System.IO.File.ReadAllBytes(uploadMp3), "application/octet-stream", Path.GetFileName(uploadMp3)); + } + return ApiResult.Fail("未上传音频"); + } + } } diff --git a/Controllers/LogsController.cs b/Controllers/LogsController.cs index e614d46..c9e56be 100644 --- a/Controllers/LogsController.cs +++ b/Controllers/LogsController.cs @@ -20,13 +20,13 @@ namespace dy.net.Controllers this.logInfoService = logInfoService; } - [HttpGet] - public async Task GetLog(string type, string date) + [HttpGet("/api/logs/GetLog/{type}/{date}")] + public async Task GetLog([FromRoute]string type, [FromRoute] string date) { var filePath = Path.Combine(webHostEnvironment.IsDevelopment() ? Directory.GetCurrentDirectory() : AppDomain.CurrentDomain.BaseDirectory, "logs", $"log-{type}-{date}.txt"); if (!System.IO.File.Exists(filePath)) { - var msg = $"Log file log-{type}-{date}.txt not found."; + var msg = $"{date},没有发现{type}的日志"; //Serilog.Log.Error(msg); return Ok(msg); } diff --git a/Controllers/VideoController.cs b/Controllers/VideoController.cs index 8d5760f..8b7166d 100644 --- a/Controllers/VideoController.cs +++ b/Controllers/VideoController.cs @@ -4,6 +4,7 @@ using dy.net.service; using dy.net.utils; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System; namespace dy.net.Controllers { @@ -270,5 +271,45 @@ namespace dy.net.Controllers var data = await douyinVideoService.DeleteInvalidVideo(); return Ok(data); } + + /// + /// 所有视频重新生成nfo文件 + /// + /// + [HttpGet("renfo")] + public async Task ReCreateNfo() + { + var videos= await douyinVideoService.GetAllAsync(); + if (videos == null || videos.Count == 0) + { + return ApiResult.Success("暂无视频数据需要生成NFO文件"); + } + _ = Task.Run(async () => + { + try + { + var totalCount = videos.Count; + foreach (var video in videos) + { + try + { + NfoFileGenerator.GenerateVideoNfoFile(video); + Serilog.Log.Debug($"刮削视频(Path:{video.VideoSavePath})生成NFO成功!"); + await Task.Delay(50); + } + catch (Exception singleEx) + { + Serilog.Log.Error($"刮削视频(Path:{video.VideoSavePath})生成NFO失败:{singleEx.Message}"); + } + } + } + catch (Exception ex) + { + Serilog.Log.Error($"NFO文件生成任务执行异常:{ex.Message}\n{ex.StackTrace}"); + } + }); + + return ApiResult.Success(); + } } } diff --git a/Properties/PublishProfiles/docker.pubxml.user b/Properties/PublishProfiles/docker.pubxml.user index 564ba8e..b7e593f 100644 --- a/Properties/PublishProfiles/docker.pubxml.user +++ b/Properties/PublishProfiles/docker.pubxml.user @@ -3,7 +3,7 @@ <_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\ - True|2026-01-11T13:25:07.5052845Z||;False|2026-01-11T21:24:27.5744557+08:00||;True|2026-01-11T19:59:15.4734611+08:00||;False|2026-01-11T19:59:04.9543339+08:00||;True|2026-01-11T18:51:30.6649269+08:00||;True|2026-01-08T21:07:57.5094466+08:00||;True|2026-01-08T14:51:18.2266231+08:00||;True|2026-01-08T14:31:55.1137120+08:00||;True|2026-01-08T00:16:24.4451490+08:00||;True|2026-01-08T00:14:20.7430793+08:00||;True|2026-01-08T00:07:46.7228194+08:00||;True|2026-01-07T23:52:13.3290082+08:00||;True|2026-01-07T23:49:28.4838650+08:00||;True|2026-01-07T23:47:45.1936189+08:00||;True|2026-01-07T23:35:09.7818611+08:00||;True|2026-01-07T23:20:43.7462863+08:00||;True|2026-01-07T23:04:39.5140429+08:00||;False|2026-01-07T23:04:36.5367611+08:00||;True|2026-01-07T22:53:14.6013449+08:00||;True|2026-01-07T22:49:59.9549006+08:00||;True|2026-01-07T22:36:40.0254856+08:00||;False|2026-01-07T22:36:28.8275903+08:00||;True|2026-01-07T21:59:51.4355171+08:00||;True|2026-01-03T21:38:51.4307034+08:00||;True|2026-01-02T19:09:16.9668906+08:00||;False|2026-01-02T19:09:11.7496369+08:00||;True|2026-01-02T15:42:08.2215697+08:00||;True|2026-01-02T09:46:56.1654861+08:00||;True|2026-01-02T09:35:28.0211225+08:00||;True|2026-01-01T23:07:01.9022045+08:00||;False|2026-01-01T23:06:56.0537216+08:00||;True|2026-01-01T22:16:10.2974067+08:00||;True|2026-01-01T22:16:06.2123787+08:00||;False|2026-01-01T22:15:33.3626979+08:00||;True|2026-01-01T22:01:30.7161900+08:00||;True|2026-01-01T21:10:19.3664263+08:00||;True|2026-01-01T21:09:38.6071080+08:00||;True|2025-12-30T11:45:54.5543034+08:00||;True|2025-12-30T09:19:08.3178124+08:00||;True|2025-12-29T18:57:29.7032246+08:00||;True|2025-12-27T14:52:24.4780776+08:00||;False|2025-12-27T14:52:19.5635794+08:00||;True|2025-12-27T14:48:01.6252748+08:00||;False|2025-12-27T14:47:55.7976192+08:00||;True|2025-12-27T14:38:23.9723838+08:00||;True|2025-12-27T13:00:29.8583858+08:00||;True|2025-12-26T22:18:42.4015637+08:00||;True|2025-12-26T22:10:58.5274572+08:00||;True|2025-12-26T22:06:26.3129600+08:00||;True|2025-12-26T22:03:55.6718618+08:00||;False|2025-12-26T22:03:48.3809954+08:00||;True|2025-12-26T22:02:33.1840390+08:00||;True|2025-12-26T22:01:16.1660100+08:00||;True|2025-12-25T00:50:26.1116465+08:00||;True|2025-12-25T00:48:27.3087708+08:00||;True|2025-12-25T00:47:38.4835720+08:00||; + True|2026-01-12T14:04:14.5886955Z||;False|2026-01-12T22:04:08.2008101+08:00||;True|2026-01-12T21:53:46.9947176+08:00||;True|2026-01-12T21:11:15.8358024+08:00||;True|2026-01-12T21:09:51.8663228+08:00||;True|2026-01-11T21:25:07.5052845+08:00||;False|2026-01-11T21:24:27.5744557+08:00||;True|2026-01-11T19:59:15.4734611+08:00||;False|2026-01-11T19:59:04.9543339+08:00||;True|2026-01-11T18:51:30.6649269+08:00||;True|2026-01-08T21:07:57.5094466+08:00||;True|2026-01-08T14:51:18.2266231+08:00||;True|2026-01-08T14:31:55.1137120+08:00||;True|2026-01-08T00:16:24.4451490+08:00||;True|2026-01-08T00:14:20.7430793+08:00||;True|2026-01-08T00:07:46.7228194+08:00||;True|2026-01-07T23:52:13.3290082+08:00||;True|2026-01-07T23:49:28.4838650+08:00||;True|2026-01-07T23:47:45.1936189+08:00||;True|2026-01-07T23:35:09.7818611+08:00||;True|2026-01-07T23:20:43.7462863+08:00||;True|2026-01-07T23:04:39.5140429+08:00||;False|2026-01-07T23:04:36.5367611+08:00||;True|2026-01-07T22:53:14.6013449+08:00||;True|2026-01-07T22:49:59.9549006+08:00||;True|2026-01-07T22:36:40.0254856+08:00||;False|2026-01-07T22:36:28.8275903+08:00||;True|2026-01-07T21:59:51.4355171+08:00||;True|2026-01-03T21:38:51.4307034+08:00||;True|2026-01-02T19:09:16.9668906+08:00||;False|2026-01-02T19:09:11.7496369+08:00||;True|2026-01-02T15:42:08.2215697+08:00||;True|2026-01-02T09:46:56.1654861+08:00||;True|2026-01-02T09:35:28.0211225+08:00||;True|2026-01-01T23:07:01.9022045+08:00||;False|2026-01-01T23:06:56.0537216+08:00||;True|2026-01-01T22:16:10.2974067+08:00||;True|2026-01-01T22:16:06.2123787+08:00||;False|2026-01-01T22:15:33.3626979+08:00||;True|2026-01-01T22:01:30.7161900+08:00||;True|2026-01-01T21:10:19.3664263+08:00||;True|2026-01-01T21:09:38.6071080+08:00||;True|2025-12-30T11:45:54.5543034+08:00||;True|2025-12-30T09:19:08.3178124+08:00||;True|2025-12-29T18:57:29.7032246+08:00||;True|2025-12-27T14:52:24.4780776+08:00||;False|2025-12-27T14:52:19.5635794+08:00||;True|2025-12-27T14:48:01.6252748+08:00||;False|2025-12-27T14:47:55.7976192+08:00||;True|2025-12-27T14:38:23.9723838+08:00||;True|2025-12-27T13:00:29.8583858+08:00||;True|2025-12-26T22:18:42.4015637+08:00||;True|2025-12-26T22:10:58.5274572+08:00||;True|2025-12-26T22:06:26.3129600+08:00||;True|2025-12-26T22:03:55.6718618+08:00||;False|2025-12-26T22:03:48.3809954+08:00||;True|2025-12-26T22:02:33.1840390+08:00||;True|2025-12-26T22:01:16.1660100+08:00||;True|2025-12-25T00:50:26.1116465+08:00||;True|2025-12-25T00:48:27.3087708+08:00||;True|2025-12-25T00:47:38.4835720+08:00||; \ No newline at end of file diff --git a/app/src/components/layout/FrontView.vue b/app/src/components/layout/FrontView.vue index dd373f8..6ef95e6 100644 --- a/app/src/components/layout/FrontView.vue +++ b/app/src/components/layout/FrontView.vue @@ -31,7 +31,6 @@ const isMobileBrowser = computed(() => { const enforceMobileRoute = () => { const targetMobilePath = '/mobile'; const currentPath = route.path.toLowerCase().trim(); - // 核心规则:手机浏览器 → 强制跳转到/mobile(无论当前路由是什么) if (isMobileBrowser.value) { if (currentPath !== targetMobilePath) { diff --git a/app/src/pages/mobile/MobileDashboard.vue b/app/src/pages/mobile/MobileDashboard.vue index 303fc66..9d38145 100644 --- a/app/src/pages/mobile/MobileDashboard.vue +++ b/app/src/pages/mobile/MobileDashboard.vue @@ -368,7 +368,7 @@ const loadLogDetailContent = (log: LogItem) => { try { const type = log.type.toLowerCase(); const date = log.date; - const requestParams = `type=${type}&date=${date}`; + const requestParams = `${type}/${date}`; useApiStore() .apiGetLogs(requestParams) diff --git a/app/src/pages/mylogs/MyLogs.vue b/app/src/pages/mylogs/MyLogs.vue index 8ef7b53..70120bd 100644 --- a/app/src/pages/mylogs/MyLogs.vue +++ b/app/src/pages/mylogs/MyLogs.vue @@ -1,92 +1,196 @@ - - - - - - - debug - error - - - - - - {{ logs }} + + + + + 普通日志 + + + 错误日志 + + + + + + 前一天 + 今天 + 下一天 + + + + + + + + {{ logs }} + - + +// ========== 可选:日志类型按钮颜色也同步调整(可选) ========== +.type-btn { + :deep(&.ant-btn-primary) { + background: #722ed1 !important; // 日志类型选中用紫色(可改) + border-color: #722ed1 !important; + color: #fff !important; + + &:hover { + background: #8046e0 !important; + border-color: #8046e0 !important; + } + } +} + \ No newline at end of file diff --git a/app/src/pages/set/AppSet.vue b/app/src/pages/set/AppSet.vue index 214bed9..8b8876f 100644 --- a/app/src/pages/set/AppSet.vue +++ b/app/src/pages/set/AppSet.vue @@ -1,12 +1,11 @@ - 任务调度 - + @@ -14,7 +13,7 @@ - + @@ -33,13 +32,13 @@ - 博主视频(仅关注有效) + 博主视频 - 启用用原标题,未启用用模板;无模板则默认用视频Id + 开启后,用原标题作为文件名,不开启但又没设置标题规则模板,则默认用视频id命名 @@ -47,7 +46,7 @@ - 默认按博主名建文件夹,启用后直接存映射目录根目录 + 默认按博主名建文件夹,开启后直接存映射目录根目录 @@ -55,8 +54,11 @@ + - 选择文件名占位符(顺序为文件名顺序,需配合分隔符)占位符:{Id}=视频ID、{VideoTitle}=标题、{ReleaseTime}=发布时间、{Author}=博主名、{FileHash}=文件哈希、{Resolution}=分辨率 + + 请选择文件名占位符和模板分隔符(文件名命名规则配置仅博主视频有效) + @@ -86,51 +88,97 @@ 图文视频 - + + + + + + 开启后,将图片文件和音频文件合成为视频文件 + + + - 开启:图文视频统一存入抖音授权 Cookie 配置的目录,且需提前配置该存储路径。关闭:按类型分别存入对应文件夹(如收藏视频存入收藏视频目录) + 开启后,图文视频统一存入抖音授权 Cookie 配置的目录,且需提前配置该存储路径。关闭后,则按类型分别存入对应文件夹(如收藏视频存入收藏视频目录) - - - - - 启用后,会将图片合成为视频文件下载 - - - - + - 启用后,将单独下载音频文件 + 开启后,将保留音频文件 - + - 启用后,将单独下载所有图片文件 - - - - - - - 针对有些视频是多个视频生成的,实际是分为多个视频,启用后将会分别下载多个视频,名字带_001,002这样 + 开启后,将保留图片文件 + + + + + + 选择默认音频文件 + + + + + + isPlaying = false" @pause="() => isPlaying = false" @play="() => isPlaying = true" class="native-audio-player"> + 您的浏览器不支持HTML5音频播放,请升级至现代浏览器。 + + + + + + + 当下载图文视频时,音频因版权原因无法下载时,将用该音频文件作为合成视频的音频 + + + + 支持格式:MP3、WAV、AAC、FLAC、OGG、M4A、WMA,单个文件最大20MB + + + + + + 动态视频 + + + + + + 针对有些视频是多个视频生成的,实际是分为多个视频,开启后将会分别下载多个视频,名字带_001,002这样 + + + + + + + 开启后将多个动态视频会合并为一个视频 + + + + + + + + 开启后,将保留合成视频之前的每个短视频 + + - 去重配置 + 视频去重 @@ -150,7 +198,6 @@ 鼠标放到≡,点击鼠标左键即可拖拽调整优先级, - @@ -168,13 +215,11 @@ - - - + @@ -183,38 +228,47 @@ - + - + + + + + + + + \ No newline at end of file diff --git a/app/src/pages/workplace/RecordTable.vue b/app/src/pages/workplace/RecordTable.vue index 5098e7e..6bac008 100644 --- a/app/src/pages/workplace/RecordTable.vue +++ b/app/src/pages/workplace/RecordTable.vue @@ -71,10 +71,13 @@ - - - {{index+1}}. {{ item.videoTitle }} - + + + {{ index + 1 }}. + + {{ item.videoTitle }} + + copyVideoPath(item.videoSavePath)"> 复制 @@ -1313,34 +1316,87 @@ onMounted(() => { text-decoration-color: #1890ff; text-decoration-thickness: 1px; } - -/* 已删除视频条目样式 */ -.delete-video-text { - flex: 1; /* 文本占满剩余空间 */ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-right: 8px; +/* 已删除视频抽屉 - 列表容器基础样式 */ +:deep(.ant-drawer-body) { + padding: 16px !important; + overflow-y: auto; } -/* 复制按钮样式 */ +:deep(.ant-list) { + margin: 0 !important; +} + +/* 已删除视频 - 列表项布局优化 */ +:deep(.ant-list-item) { + display: flex !important; + align-items: center !important; + justify-content: space-between !important; + padding: 12px 16px !important; + border-bottom: 1px solid #f0f0f0 !important; + transition: background-color 0.2s ease; +} + +/* 列表项悬停效果,增强交互感 */ +:deep(.ant-list-item:hover) { + background-color: #f8f9fa !important; +} + +/* 已删除视频 - 标题容器(核心:实现单行省略) */ +.delete-video-title-container { + display: flex; + align-items: center; + flex: 1; /* 占满左侧剩余空间,限制文本宽度 */ + margin-right: 16px; /* 与复制按钮保持间距 */ + overflow: hidden; /* 隐藏溢出内容 */ +} + +/* 序号样式 */ +.delete-video-index { + color: #666; + margin-right: 8px; + flex: 0 0 auto; /* 序号不收缩、不放大,固定宽度 */ + white-space: nowrap; +} + +/* 视频标题(核心:单行文本溢出省略) */ +.delete-video-title { + flex: 1; /* 占满容器剩余空间,触发宽度限制 */ + white-space: nowrap; /* 强制文本单行显示 */ + overflow: hidden; /* 隐藏溢出的文本 */ + text-overflow: ellipsis; /* 溢出部分显示省略号... */ + color: #333; + font-size: 14px; + line-height: 1.5; +} + +/* 复制按钮样式优化 */ .copy-delete-video-btn { - padding: 0 4px !important; - height: 24px !important; + padding: 0 8px !important; + height: 28px !important; font-size: 12px !important; color: #1890ff !important; + flex: 0 0 auto; /* 按钮不收缩、不放大,固定宽度 */ } .copy-delete-video-btn:hover { color: #40a9ff !important; background-color: #f0f9ff !important; + border-radius: 4px !important; } -/* 列表项布局调整 */ -:deep(.ant-list-item) { - display: flex !important; - align-items: center !important; - justify-content: space-between !important; - padding: 8px 16px !important; +/* 可选:适配移动端,优化小屏幕显示 */ +@media (max-width: 768px) { + .delete-video-title-container { + margin-right: 12px; + } + + .delete-video-title { + font-size: 13px; + } + + .copy-delete-video-btn { + padding: 0 6px !important; + height: 24px !important; + } } \ No newline at end of file diff --git a/app/src/router/guards.ts b/app/src/router/guards.ts index c521d20..5bc160a 100644 --- a/app/src/router/guards.ts +++ b/app/src/router/guards.ts @@ -13,21 +13,69 @@ interface NaviGuard { after?: NavigationHookAfter; } -const loginGuard: NavigationGuard = function (to, from) { - // console.log('Authorization', http.checkAuthorization()) - const account = useAccountStore(); - if (!http.checkAuthorization() && !/^\/(init|login|home|mobile)?$/.test(to.fullPath)) { - console.log(123) - console.log(to.fullPath) - account.setLogged(false) - return '/login'; - } else { - } - +// ========== 新增:移动端检测核心函数 ========== +/** + * 检测是否为移动端设备(UA + 屏幕宽度双检测) + */ +const isMobile = (): boolean => { + const userAgent = navigator.userAgent.toLowerCase(); + const mobileUaReg = /iphone|android|ipad|ipod|mobile|wap|symbian|windows ce|blackberry|webos|ucbrowser/i; + const isSmallScreen = window.innerWidth < 768; + return mobileUaReg.test(userAgent) || isSmallScreen; }; -const dynamicinitRoute = -{ +// 标记是否已跳转到移动端路由,防止无限循环 +let hasRedirectedToMobile = false; + +// ========== 新增:移动端跳转守卫(已集成登录状态判断) ========== +const MobileRedirectGuard: NavigationGuard = function (to, from, next) { + // 1. 排除/mobile路由本身,避免无限循环 + if (to.path === '/mobile') { + hasRedirectedToMobile = true; + next(); + return; + } + + // 2. 排除/login路由,避免登录页被移动端跳转逻辑覆盖 + if (to.path === '/login') { + next(); + return; + } + + // 3. 检测是否为移动端 + if (isMobile() && !hasRedirectedToMobile) { + // 4. 核心判断:检查登录状态 + const isAuthorized = http.checkAuthorization(); + if (!isAuthorized) { + // 未登录:优先跳转到登录页 + hasRedirectedToMobile = false; // 重置标记,不影响后续登录后的跳转 + next('/login'); + } else { + // 已登录:跳转到移动端路由 + hasRedirectedToMobile = true; + next({ path: '/mobile' }); + } + } else { + // 非移动端/已跳转:重置标记并执行原有逻辑 + hasRedirectedToMobile = false; + next(); + } +}; + +// ========== 原有守卫逻辑(无修改) ========== +const loginGuard: NavigationGuard = function (to, from, next) { + // 补充next参数,保证守卫链正常执行 + if (!http.checkAuthorization() && !/^\/(init|login|home|mobile)?$/.test(to.fullPath)) { + console.log(to.fullPath) + const account = useAccountStore(); + account.setLogged(false); + next('/login'); + } else { + next(); + } +}; + +const dynamicinitRoute = { path: '/', name: 'login', redirect: '/login', @@ -40,22 +88,23 @@ const dynamicinitRoute = component: () => import('@/pages/login'), }; - -const InitGuard: NavigationGuard = function (to, from) { - +const InitGuard: NavigationGuard = function (to, from, next) { + // 补充next参数 if (to.fullPath != '/login') { if (!router.hasRoute('login')) { - router.addRoute(dynamicinitRoute) + router.addRoute(dynamicinitRoute); } - router.push('/login') + next('/login'); + } else { + next(); } }; - // 进度条 const ProgressGuard: NaviGuard = { - before(to, from) { + before(to, from, next) { NProgress.start(); + next(); // 补充next参数 }, after(to, from) { NProgress.done(); @@ -63,16 +112,18 @@ const ProgressGuard: NaviGuard = { }; const AuthGuard: NaviGuard = { - before(to, from) { + before(to, from, next) { const { hasAuthority } = useAuthStore(); if (to.meta?.permission && !hasAuthority(to.meta?.permission)) { - return { name: '403', query: { permission: to.meta.permission, path: to.fullPath } }; + next({ name: '403', query: { permission: to.meta.permission, path: to.fullPath } }); + } else { + next(); } }, }; const ForbiddenGuard: NaviGuard = { - before(to) { + before(to, from, next) { if (to.name === '403' && (to.query.permission || to.query.path)) { to.fullPath = to.fullPath .replace(/permission=[^&=]*&?/, '') @@ -83,21 +134,46 @@ const ForbiddenGuard: NaviGuard = { delete to.query.permission; delete to.query.path; } + next(); // 补充next参数 }, }; // 404 not found const NotFoundGuard: NaviGuard = { - before(to, from) { + before(to, from, next) { const { loading } = useMenuStore(); if (to.meta._is404Page && loading) { to.params.loading = true as any; } + next(); // 补充next参数 }, }; +// ========== 页面刷新时的移动端检测(已集成登录状态判断) ========== +window.addEventListener('load', () => { + if (isMobile() && window.location.pathname !== '/mobile') { + // 检查登录状态:未登录则跳登录,已登录则跳移动端 + const isAuthorized = http.checkAuthorization(); + if (!isAuthorized) { + if (window.location.pathname !== '/login') { + router.push('/login').catch(err => { + if (!err.message.includes('NavigationDuplicated')) { + console.error('刷新时跳转登录页失败:', err); + } + }); + } + } else { + router.push('/mobile').catch(err => { + if (!err.message.includes('NavigationDuplicated')) { + console.error('刷新时跳转移动端路由失败:', err); + } + }); + } + } +}); + export default { - // before: [ProgressGuard.before, InitGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before], - before: [ProgressGuard.before, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before], + // 把MobileRedirectGuard放在最前面,优先执行移动端检测 + before: [ProgressGuard.before, MobileRedirectGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before], after: [ProgressGuard.after], -}; +}; \ No newline at end of file diff --git a/app/src/store/coreapi.ts b/app/src/store/coreapi.ts index 83349aa..e01ea83 100644 --- a/app/src/store/coreapi.ts +++ b/app/src/store/coreapi.ts @@ -2,6 +2,7 @@ import { defineStore, storeToRefs } from 'pinia'; import http from './http'; import { ref, watch } from 'vue'; import { Response } from '@/types'; + // import { RouteOption } from '@/router/interface'; // import { addRoutes, removeRoute } from '@/router/dynamicRoutes'; // import { useSettingStore } from './setting'; @@ -36,7 +37,6 @@ export const useApiStore = defineStore('coreapi', () => { return http .request>('/api/config/GetConfig', 'GET') .then((res) => { - console.log(res) return res; }) .finally(() => { @@ -57,7 +57,7 @@ export const useApiStore = defineStore('coreapi', () => { } //后台日志 async function apiGetLogs(param: string) { - return http.request>('/api/logs/GetLog?' + param, 'get').then(r => { + return http.request>('/api/logs/GetLog/' + param, 'get').then(r => { // console.log(r) return r.data; }).finally(() => { @@ -296,7 +296,43 @@ export const useApiStore = defineStore('coreapi', () => { }); } + + //Renfo + async function Renfo() { + return http.request>('/api/Video/renfo', 'get').then(r => { + return r; + }).finally(() => { + + }); + } + + + // 音频文件上传接口 + async function apiUploadAudio(formData: FormData, options?: { onUploadProgress?: (progressEvent: ProgressEvent) => void }) { + return http + .request>( + '/api/config/UploadAudio', // 请求地址 + 'post_form', // 使用新增的 post_form 类型 + formData, // FormData 参数(文件+其他参数) + { + onUploadProgress: options?.onUploadProgress, // 上传进度回调(原生 ProgressEvent) + timeout: 120000 // 上传文件超时时间设为2分钟(可选) + } + ) + .then((res) => { + // console.log('音频上传结果:', res); + // 适配你的响应格式(如果响应是包裹层,取 data) + return res; + }) + .catch((err) => { + console.error('音频上传失败:', err); + throw err; // 抛出错误让前端捕获 + }); + } + return { + Renfo, + apiUploadAudio, GetAppPort, AppisInit, DeskInitAsync, diff --git a/app/src/store/http.ts b/app/src/store/http.ts index 86ed933..b098d8a 100644 --- a/app/src/store/http.ts +++ b/app/src/store/http.ts @@ -4,7 +4,8 @@ import { isResponse } from '@/types'; import NProgress from 'nprogress'; import { useAccountStore } from '@/store'; import { message } from 'ant-design-vue'; -import router from '@/router'; // 关键:导入路由实例(路径要和实际一致) +import router from '@/router'; + const http = createHttp({ timeout: 60000, baseURL: '/', @@ -17,7 +18,10 @@ const isAxiosResponse = (obj: any): obj is AxiosResponse => { return typeof obj === 'object' && obj.status && obj.statusText && obj.headers && obj.config; }; -// progress 进度条 -- 开启 +// 仅新增这一行:跳转锁 +let isRedirecting = false; + +// progress 进度条 -- 开启(和你原本一致) http.interceptors.request.use((req: AxiosRequestConfig) => { if (!NProgress.isStarted()) { NProgress.start(); @@ -25,7 +29,7 @@ http.interceptors.request.use((req: AxiosRequestConfig) => { return req; }); -// 解析响应结果 +// 解析响应结果(完全和你原本一致,一字未改) http.interceptors.response.use( (rep: AxiosResponse) => { const { data } = rep; @@ -35,26 +39,34 @@ http.interceptors.response.use( return Promise.reject({ message: rep.statusText, code: rep.status, data }); }, (error) => { - if (error.response.status === 401) { - const accountStore = useAccountStore(); - // 1. 清除登录状态 - accountStore.setLogged(false); - // 可选:提示用户登录过期 - message.warning('登录状态已过期,请重新登录'); // 如使用Element Plus + if (error.response?.status === 401) { + // 仅新增:加锁判断(这是唯一改动) + if (!isRedirecting) { + isRedirecting = true; + const accountStore = useAccountStore(); + accountStore.setLogged(false); + message.warning('登录状态已过期,请重新登录'); - setTimeout(() => { - const redirectPath = router.currentRoute.value.fullPath; - router.push({ - path: '/login', - query: { redirect: redirectPath } - }).then(() => { - console.log('跳转登录页成功'); - }).catch((err) => { - console.error('跳转登录页失败:', err); // 关键!捕获跳转失败的原因 - }); - }, 100); - + setTimeout(() => { + const redirectPath = router.currentRoute.value.fullPath; + if (redirectPath !== '/login') { + router.push({ + path: '/login', + query: { redirect: redirectPath } + }).then(() => { + console.log('跳转登录页成功'); + }).catch((err) => { + console.error('跳转登录页失败:', err); + }).finally(() => { + isRedirecting = false; + }); + } else { + isRedirecting = false; + } + }, 100); + } + // 新增结束 } else { if (error.response && isAxiosResponse(error.response)) { return Promise.reject({ @@ -69,7 +81,7 @@ http.interceptors.response.use( } ); -// progress 进度条 -- 关闭 +// progress 进度条 -- 关闭(改回你原本的逻辑,不碰返回值) http.interceptors.response.use( (rep) => { if (NProgress.isStarted()) { @@ -81,8 +93,9 @@ http.interceptors.response.use( if (NProgress.isStarted()) { NProgress.done(); } + // 改回你原本的返回值:return error(之前改这个导致登录异常) return error; } ); -export default http; +export default http; \ No newline at end of file diff --git a/app/src/utils/axiosHttp.ts b/app/src/utils/axiosHttp.ts index 3a21f72..2adebe9 100644 --- a/app/src/utils/axiosHttp.ts +++ b/app/src/utils/axiosHttp.ts @@ -1,5 +1,4 @@ -import axios, { AxiosInstance, AxiosRequestConfig, Method as _Method, AxiosResponse } from 'axios'; - +import axios, { AxiosInstance, AxiosRequestConfig, Method as _Method, AxiosResponse } from 'axios'; // 移除 AxiosProgressEvent import qs from 'qs'; import Cookie from 'js-cookie'; @@ -9,13 +8,13 @@ declare interface _AxiosExtend { * @param url 请求地址 * @param method 请求方法 * @param params 请求参数 - * @param config 请求配置 + * @param config 请求配置(新增上传进度回调) */ request>( url: string, method: Method, - params?: Record, - config?: AxiosRequestConfig + params?: Record | FormData, // 支持 FormData 类型 + config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void } // 改用原生 ProgressEvent ): Promise; /** * 设置token @@ -41,7 +40,8 @@ declare interface _AxiosExtend { export interface AxiosHttp extends Omit, _AxiosExtend { } -export type Method = _Method | 'POST_JSON' | 'post_json' | 'PUT_JSON' | 'put_json'; +// 新增 post_form / POST_FORM 类型 +export type Method = _Method | 'POST_JSON' | 'post_json' | 'PUT_JSON' | 'put_json' | 'POST_FORM' | 'post_form'; /** * 转表单格式 @@ -106,10 +106,16 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp { request>( url: string, method: Method, - params?: Record, - config?: AxiosRequestConfig + params?: Record | FormData, + config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void } // 改用原生 ProgressEvent ): Promise { const _method = method.toUpperCase(); + // 处理上传进度配置 + const requestConfig: AxiosRequestConfig = { + ...config, + onUploadProgress: config?.onUploadProgress, // 透传上传进度回调 + }; + switch (_method) { case 'GET': return _axios.get(url, { @@ -117,24 +123,37 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp { paramsSerializer: (data) => { return qs.stringify(data, { indices: false, skipNulls: true }); }, - ...config, + ...requestConfig, }); case 'POST': - return _axios.post(url, toUrlencoded(params), config); + return _axios.post(url, toUrlencoded(params as Record), requestConfig); case 'POST_JSON': - return _axios.post(url, params, config); + return _axios.post(url, params, { + ...requestConfig, + headers: { 'Content-Type': 'application/json', ...requestConfig.headers }, + }); + // 新增:POST_FORM 类型(适配文件上传的 FormData) + case 'POST_FORM': + return _axios.post(url, params, { + ...requestConfig, + // FormData 不需要手动设置 Content-Type,axios 会自动处理为 multipart/form-data + headers: { ...requestConfig.headers }, + }); case 'PUT': - return _axios.put(url, toFormData(params), config); + return _axios.put(url, toFormData(params as Record), requestConfig); case 'PUT_JSON': - return _axios.put(url, params, config); + return _axios.put(url, params, { + ...requestConfig, + headers: { 'Content-Type': 'application/json', ...requestConfig.headers }, + }); case 'DELETE': - return _axios.delete(url, { data: toFormData(params), ...config }); + return _axios.delete(url, { data: toFormData(params as Record), ...requestConfig }); case 'HEAD': - return _axios.head(url, { params, ...config }); + return _axios.head(url, { params, ...requestConfig }); case 'OPTIONS': - return _axios.options(url, { params, ...config }); + return _axios.options(url, { params, ...requestConfig }); case 'PATCH': - return _axios.patch(url, { params, ...config }); + return _axios.patch(url, { params, ...requestConfig }); case 'PURGE': case 'LINK': case 'UNLINK': @@ -158,4 +177,4 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp { return http; } -export default createAxiosHttp; +export default createAxiosHttp; \ No newline at end of file diff --git a/job/DouyinBasicSyncJob.cs b/job/DouyinBasicSyncJob.cs index d4b3058..f410b04 100644 --- a/job/DouyinBasicSyncJob.cs +++ b/job/DouyinBasicSyncJob.cs @@ -512,8 +512,44 @@ namespace dy.net.job var dynamicVideo = await ProcessDynamicVideo(dynamicVideoUrls, cookie, item, data, config); if (dynamicVideo != null) { + if(!string.IsNullOrEmpty(dynamicVideo.DynamicVideos)) + { + var dynamicVideos = JsonConvert.DeserializeObject>(dynamicVideo.DynamicVideos); + Log.Debug($"{VideoType}-动态视频[{item.Desc}],下载成功 ,共{dynamicVideos?.Count}个视频..."); + if (config.MegDynamicVideo) + { + if (dynamicVideos != null && dynamicVideos.Count > 0) + { + int width = 1080; + int height = 1920; + var bit = item?.Video?.BitRate?.FirstOrDefault(); + if (bit != null) + { + width = bit.PlayAddr.Width; + height = bit.PlayAddr.Height; + } + var savePath = DouyinFileNameHelper.RemoveNumberSuffix(dynamicVideo.VideoSavePath); + var outPath= await douyinMergeVideoService.MergeMultipleVideosAsync(dynamicVideos, savePath, width, height); + if (File.Exists(outPath)) + { + dynamicVideo.VideoSavePath = outPath; + + if (!config.KeepDynamicVideo) + { + foreach (var opath in dynamicVideos) + { + if (File.Exists(opath)) + File.Delete(opath); + } + } + } + + } + } + } + videos.Add(dynamicVideo); - Log.Debug($"{VideoType}-动态视频[{item.Desc}],下载成功 ,共{dynamicVideo.DynamicVideos.Count()}个视频..."); + } else { @@ -774,10 +810,9 @@ namespace dy.net.job await DownVideoCover(item, saveFolder, cookie, config); // 下载作者头像 var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item); - // 生成NFO文件 - await GenerateNfoFile(saveFolder, item, avatarUrl, cookie, config); + // 创建视频实体 - return CreateVideoEntity(config,cookie, item, v, savePath, saveFolder, tag1, tag2, tag3, avatarSavePath, avatarUrl, data); + return await CreateVideoEntity(config,cookie, item, v, savePath, saveFolder, tag1, tag2, tag3, avatarSavePath, avatarUrl, data); } @@ -846,7 +881,7 @@ namespace dy.net.job DataSize = DouyinFileUtils.GetTotalFileSize(dynamicSavePaths) // 合成视频的文件大小 } }; - return CreateVideoEntity(config,cookie, item, virtualBitRate, dynamicSavePaths.FirstOrDefault(), saveFolder, tag1, tag2, tag3,"", "", data,dynamicSavePaths); + return await CreateVideoEntity(config,cookie, item, virtualBitRate, dynamicSavePaths.FirstOrDefault(), saveFolder, tag1, tag2, tag3,"", "", data,dynamicSavePaths); } @@ -1025,9 +1060,7 @@ namespace dy.net.job await DownVideoCover(imageUrls.FirstOrDefault(), fileNamefolder, cookie, item, config); // 下载作者头像 var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item); - // 生成NFO文件 - await GenerateNfoFile(fileNamefolder, item, avatarUrl, cookie, config); - + // 获取视频标签 var (tag1, tag2, tag3) = GetVideoTags(item); // 为合成的视频创建一个“虚拟”的BitRate对象,以便复用CreateVideoEntity方法 @@ -1042,7 +1075,7 @@ namespace dy.net.job }; // 创建视频实体 - var videoEntity = CreateVideoEntity(config, + var videoEntity =await CreateVideoEntity(config, cookie, item, virtualBitRate, savePath, fileNamefolder, tag1, tag2, tag3, avatarSavePath, avatarUrl, data); @@ -1084,45 +1117,7 @@ namespace dy.net.job } } - /// - /// 生成NFO文件 - /// NFO文件包含视频的元数据信息,如标题、作者、封面等 - /// - /// NFO文件的保存文件夹 - /// 视频信息 - /// 作者头像保存路径 - /// - /// - /// 一个表示异步操作的任务 - protected async Task GenerateNfoFile(string saveFolder, Aweme item, - string avatarSavePath, DouyinCookie cookie, AppConfig config) - { - // 异步生成NFO文件,避免阻塞主线程 - await Task.Run(() => - { - (string tag1, string tag2, string tag3) = GetVideoTags(item); - var nfoFileName = GetNfoFileName(cookie, item, config, ".nfo"); - var poster = GetNfoFileName(cookie, item, config, "poster.jpg"); - var nfoPath = Path.Combine(saveFolder, nfoFileName); - NfoFileGenerator.GenerateNfoFile(new DouyinVideoNfo - { - Actors = new List - { - new() { - Name = item.Author?.Nickname, - Role = "主演", - Thumb = avatarSavePath - } - }, - Author = item.Author?.Nickname, - Poster = poster, - Title = item.Desc, - Thumbnail = poster,// 使用poster作为缩略图 - ReleaseDate = DateTimeUtil.Convert10BitTimestamp(item.CreateTime), - Genres = new List { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList() - }, nfoPath); - }); - } + /// /// 下载视频封面 @@ -1275,7 +1270,7 @@ namespace dy.net.job /// 视频信息对象 /// 动态视频 /// 创建的视频实体对象 - private DouyinVideo CreateVideoEntity(AppConfig config, + private async Task CreateVideoEntity(AppConfig config, DouyinCookie cookie, Aweme item, VideoBitRate bitRate, string savePath, string saveFolder, string tag1, string tag2, string tag3, string avatarSavePath, string avatarUrl, DouyinVideoInfo data,List dynamicVideos=null) { @@ -1290,7 +1285,7 @@ namespace dy.net.job AuthorAvatar = avatarSavePath, AuthorAvatarUrl = avatarUrl, CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime), - VideoTitle = item.Desc, + VideoTitle = string.IsNullOrWhiteSpace(item.Desc) ? $"{item.Author?.Nickname}-{item.CreateTime}" : item.Desc, VideoTitleSimplify = diffs.VideoTitleSimplify, Id = IdGener.GetLong().ToString(), Resolution = $"{bitRate.PlayAddr.Width}×{bitRate.PlayAddr.Height}", @@ -1312,6 +1307,10 @@ namespace dy.net.job { video.DynamicVideos = JsonConvert.SerializeObject(dynamicVideos); } + + // 生成NFO文件 + NfoFileGenerator.GenerateVideoNfoFile(video); + return video; } diff --git a/model/AppConfig.cs b/model/AppConfig.cs index c7354fc..3d21571 100644 --- a/model/AppConfig.cs +++ b/model/AppConfig.cs @@ -24,7 +24,7 @@ namespace dy.net.model /// /// 每次查询数量 /// - public int BatchCount { get; set; } = 10; + public int BatchCount { get; set; } = 18; /// /// 博主视频是否 直接用标题做文件名 @@ -92,6 +92,14 @@ namespace dy.net.model /// 仅同步新视频(github 有人提议增加这个配置项,因为之前收藏了很多烂七八糟的视频。不想同步,又不想一个一个清除) /// public bool OnlySyncNew { get; set; } = true; + /// + /// 是否合并下载动态图视频 + /// + public bool MegDynamicVideo { get; set; } + /// + /// 保留原动态视频文件 + /// + public bool KeepDynamicVideo { get; set; } } } diff --git a/service/DouyinCommonService.cs b/service/DouyinCommonService.cs index 8ddefe1..9d5f8a3 100644 --- a/service/DouyinCommonService.cs +++ b/service/DouyinCommonService.cs @@ -36,6 +36,7 @@ namespace dy.net.service conf.PriorityLevel = "[{\"id\":1,\"name\":\"喜欢的视频\",\"sort\":1},{\"id\":2,\"name\":\"收藏的视频\",\"sort\":2},{\"id\":3,\"name\":\"关注的视频\",\"sort\":3}]"; } conf.IsFirstRunning = true;//标记为程序刚启动第一次运行 + conf.AutoDistinct = true; sqlSugarClient.Updateable(conf).ExecuteCommand(); //兼容旧版本 return conf; @@ -60,7 +61,10 @@ namespace dy.net.service AutoDistinct = true,//默认开启 PriorityLevel = "[{\"id\":1,\"name\":\"喜欢的视频\",\"sort\":1},{\"id\":2,\"name\":\"收藏的视频\",\"sort\":2},{\"id\":3,\"name\":\"关注的视频\",\"sort\":3}]", IsFirstRunning = true, - OnlySyncNew = true + OnlySyncNew = true, + DownDynamicVideo = false, + KeepDynamicVideo = false, + MegDynamicVideo = false }; sqlSugarClient.Insertable(config).ExecuteCommand(); return config; diff --git a/service/DouyinMergeVideoService.cs b/service/DouyinMergeVideoService.cs index 92fccb3..f18466c 100644 --- a/service/DouyinMergeVideoService.cs +++ b/service/DouyinMergeVideoService.cs @@ -11,12 +11,10 @@ namespace dy.net.service /// public class DouyinMergeVideoService { - private readonly FFmpegHelper _fFmpegHelper; private readonly DouyinHttpClientService douyinHttpClientService; - public DouyinMergeVideoService(FFmpegHelper fFmpegHelper,DouyinHttpClientService douyinHttpClientService) + public DouyinMergeVideoService(DouyinHttpClientService douyinHttpClientService) { - _fFmpegHelper = fFmpegHelper; this.douyinHttpClientService = douyinHttpClientService; } @@ -27,142 +25,23 @@ namespace dy.net.service // 重试间隔(毫秒) private const int RetryDelay = 1000; + /// - /// 视频合成 + /// 多视频合成一个视频 /// - /// - /// - /// - /// - /// - /// - /// - /// + /// + /// + /// + /// /// - //public async Task MergeToVideo(string cookie,string rootPath, MediaMergeRequest request,string outputVideoPath,string fileNamefolder,bool mergeImg2Viedo,bool downImage=false,bool downMp3=false) - //{ - - // try - // { - // // 创建唯一临时目录(避免并发冲突) - // var tempDir = Path.Combine(rootPath, "temp", Guid.NewGuid().ToString()); - // try - // { - // // 1. 下载图片 - // var (rawImages, error) = await DownloadMediaAsync(request.ImageUrls, Path.Combine(tempDir, "raw-images"), "image_", "webp",cookie); - // if (!string.IsNullOrEmpty(error)) - // { - // Serilog.Log.Error($"{error}"); - // return false; - // } - // else - // { - // if(downImage) - // { - // for (int i = 0; i < rawImages.Length; i++) - // { - // string sourcePath = rawImages[i]; - // // 重命名为有规律的文件名,如 temp_001.jpg, temp_002.png - // string extension = Path.GetExtension(sourcePath); - // string destFileName = $"temp_{i + 1:D3}{extension}"; // D3 确保是3位数字,不足补0 - // string destPath = Path.Combine(fileNamefolder, destFileName); - // if (destPath.Contains("小可爱") || sourcePath.Contains("小可爱")) { - // Console.WriteLine("发现小可爱图片"); - // } - // if (!File.Exists(destPath)) - // File.Copy(sourcePath, destPath); - // } - // } - // } - // // 2. 下载音频 - // var (rawAudios, audioError) = await DownloadMediaAsync(request.AudioUrls, Path.Combine(tempDir, "raw-audios"), "audio_", "mp3", cookie); - // if (!string.IsNullOrEmpty(audioError)) - // { - // Serilog.Log.Error($"{audioError}"); - // return false; - // } - // else - // { - // if(downMp3) - // { - // for (int i = 0; i < rawAudios.Length; i++) - // { - // string sourcePath = rawAudios[i]; - // // 重命名为有规律的文件名,如 temp_001.mp3, temp_002.mp3 - // string extension = Path.GetExtension(sourcePath); - // string destFileName = $"temp_{i + 1:D3}{extension}"; // D3 确保是3位数字,不足补0 - // string destPath = Path.Combine(fileNamefolder, destFileName); - // if (!File.Exists(destPath)) - // File.Copy(sourcePath, destPath); - // } - // } - // } - // if (!mergeImg2Viedo) - // { - // // 不合成视频,直接返回成功 - // Serilog.Log.Debug($"不合成视频,直接返回"); - // return true; - // } - - // // 4. 合成视频 - // //var outputVideoPath = Path.Combine(tempDir, "output", $"merged-video.{request.OutputFormat.ToLower()}"); - - // // 2. 创建帮助类实例 - // // 在Docker容器内,FFmpeg通常在PATH中,所以直接用 "ffmpeg" 即可 - - // // 根据图片数量调整每张图片显示时长 - // if (request.ImageUrls.Count <= 3) - // { - // request.ImageDurationPerSecond = 3; - // } - // if (request.ImageUrls.Count > 20) - // { - // request.ImageDurationPerSecond = 2; - // } - // // 3. (可选)自定义视频参数 - // _fFmpegHelper.VideoWidth = 1080; - // _fFmpegHelper.VideoHeight = 1920; - // _fFmpegHelper.ImageDisplayDurationSeconds = request.ImageDurationPerSecond; - // _fFmpegHelper.OutputFrameRate = 30; - - // // 4. 创建进度 - // var progress = new Progress(p => - // { - // Console.WriteLine($"进度: {p:F2}%"); - // }); - - // // 5. 执行合成任务 - // using (var cancellationTokenSource = new CancellationTokenSource()) - // { - // string resultPath = await _fFmpegHelper.CreateVideoFromImagesAndAudioAsync( - // rawImages, - // rawAudios[0], - // outputVideoPath, - // request.VideoWidth, - // request.VideoHeight, - // progress, - // cancellationTokenSource.Token); - - // //Console.WriteLine($"视频合成成功!文件已保存至: {resultPath}"); - // Serilog.Log.Debug($"视频合成成功!文件已保存至: {resultPath}"); - // } - // } - // finally - // { - // // 清理临时目录(无论成功失败) - // if (Directory.Exists(tempDir)) - // { - // Directory.Delete(tempDir, recursive: true); - // } - // } - // return true; - // } - // catch (Exception ex) - // { - // Serilog.Log.Error($"{ex.StackTrace}"); - // return false; - // } - //} + public async Task MergeMultipleVideosAsync( + List videoFilePaths, + string savePath, + int width = 1080, + int height = 1920) + { + return await new FFmpegHelper().MergeMultipleVideosAsync(videoFilePaths, savePath, width, height); + } /// @@ -261,7 +140,16 @@ namespace dy.net.service if (rawAudios.Length == 0) { - rawAudios= new string[] { Path.Combine(AppContext.BaseDirectory,"mp3", "silent_10.mp3") }; + + var mp3Path = Path.Combine(AppContext.BaseDirectory, "mp3", "silent_10.mp3"); + var uploadMp3 = Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "mp3")) + .Where(filePath => Path.GetFileNameWithoutExtension(filePath) != "silent_10") + .FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(uploadMp3) && File.Exists(uploadMp3)) + { + mp3Path = uploadMp3; + } + rawAudios = new string[] { mp3Path }; Log.Debug("版权原因无法下载音频,使用默认无声音频文件"); } @@ -298,7 +186,6 @@ namespace dy.net.service } } - /// /// 保存下载的文件(图片/音频)到目标目录 /// @@ -356,8 +243,6 @@ namespace dy.net.service return false; } - - /// /// 安全清理临时目录(避免文件被占用) /// diff --git a/service/DouyinVideoService.cs b/service/DouyinVideoService.cs index 89e06e4..e7015fd 100644 --- a/service/DouyinVideoService.cs +++ b/service/DouyinVideoService.cs @@ -4,6 +4,7 @@ using dy.net.model; using dy.net.repository; using dy.net.utils; using Newtonsoft.Json; +using System.Collections.Generic; using System.ComponentModel; using System.Threading.Tasks; @@ -124,7 +125,7 @@ namespace dy.net.service data.GraphicVideoSize = "<0.01";//避免显示0.00误导用户 } } - data.Authors = list.GroupBy(x => x.Author).Select(x => new VideoStaticsItemDto { Name = x.Key, Count = x.LongCount(), Icon = x.FirstOrDefault().AuthorAvatarUrl }).OrderByDescending(d => d.Count).ToList(); + data.Authors = list.GroupBy(x => x.Author).Select(x => new VideoStaticsItemDto { Name = x.Key, Count = x.LongCount(), Icon = x.LastOrDefault().AuthorAvatarUrl }).OrderByDescending(d => d.Count).ToList(); return data; } @@ -148,6 +149,11 @@ namespace dy.net.service return await _dyCollectVideoRepository.GetPagedAsync(dto); } + public async Task> GetAllAsync() + { + return await _dyCollectVideoRepository.GetAllAsync(); + } + /// /// 关注的博主的视频如果配置为视频标题作为文件名,生成文件名 /// diff --git a/utils/DouyinFileNameHelper.cs b/utils/DouyinFileNameHelper.cs index eb6c2fb..a75ca33 100644 --- a/utils/DouyinFileNameHelper.cs +++ b/utils/DouyinFileNameHelper.cs @@ -99,5 +99,20 @@ namespace dy.net.utils // 忽略文化差异,仅按字符编码匹配 return Regex.IsMatch(input, pattern, RegexOptions.None); } + + + /// + /// 去掉动态视频001_002 + /// + /// + /// + public static string RemoveNumberSuffix(string fileName) + { + if (string.IsNullOrEmpty(fileName)) + return fileName; + // 核心正则:只匹配「_+数字」且后面紧跟.的情况 + var pattern = @"_\d+(?=\.)"; + return Regex.Replace(fileName, pattern, ""); + } } } \ No newline at end of file diff --git a/utils/FFmpegHelper.cs b/utils/FFmpegHelper.cs index dc8a121..59cf556 100644 --- a/utils/FFmpegHelper.cs +++ b/utils/FFmpegHelper.cs @@ -229,7 +229,113 @@ namespace dy.net.utils } } - + /// + /// 合并多个视频文件为一个MP4视频 + /// + /// 待合并的视频路径列表(按合并顺序排列) + /// 输出视频的保存路径 + /// 输出视频宽度(自动修正为偶数) + /// 输出视频高度(自动修正为偶数) + /// 进度回调 + /// 取消令牌 + /// 输出视频路径 + public async Task MergeMultipleVideosAsync( + List videoFilePaths, + string savePath, + int width = 1080, + int height = 1920, + IProgress progress = null, + CancellationToken cancellationToken = default) + { + // 输入验证 + if (videoFilePaths == null || !videoFilePaths.Any()) + throw new ArgumentException("视频路径列表不能为空。", nameof(videoFilePaths)); + + foreach (var videoPath in videoFilePaths) + { + if (!File.Exists(videoPath)) + throw new FileNotFoundException("视频文件未找到。", videoPath); + } + + if (string.IsNullOrEmpty(savePath)) + throw new ArgumentNullException(nameof(savePath)); + + // 自动修正分辨率为偶数(H264编码要求) + if (width % 2 != 0) width++; + if (height % 2 != 0) height++; + + // 步骤1:创建临时文件列表(FFmpeg合并视频需要先生成文件列表) + string tempListFile = Path.Combine(AppContext.BaseDirectory, "temp", $"{Guid.NewGuid()}.txt"); + var tempDir = Path.GetDirectoryName(tempListFile); + if (!Directory.Exists(tempDir)) + { + Directory.CreateDirectory(tempDir); + } + + try + { + // 生成FFmpeg识别的文件列表(格式:file '绝对路径') + var fileListContent = new StringBuilder(); + foreach (var videoPath in videoFilePaths) + { + // 处理路径中的特殊字符,确保跨平台兼容 + string escapedPath = videoPath.Replace("\\", "/").Replace("'", "\\'"); + fileListContent.AppendLine($"file '{escapedPath}'"); + } + File.WriteAllText(tempListFile, fileListContent.ToString(), Encoding.UTF8); + + // 步骤2:构建FFmpeg合并参数 + var arguments = new List + { + "-y", // 覆盖输出文件 + "-f", "concat", // 指定合并格式 + "-safe", "0", // 允许访问绝对路径 + "-i", tempListFile, // 输入文件列表 + + // 视频编码参数(复用现有类的编码配置,保证输出格式统一) + "-c:v", VideoCodec, + "-preset", VideoPreset, + "-crf", $"{VideoCrf}", + "-s", $"{width}x{height}", // 统一输出分辨率 + "-pix_fmt", "yuv420p", // 兼容所有播放器 + "-profile:v", "main", + + // 音频编码参数 + "-c:a", AudioCodec, + "-b:a", AudioBitrate, + "-ac", "2", // 立体声 + "-ar", "44100", // 标准采样率 + + // 封装优化 + "-f", "mp4", + "-movflags", "+faststart", // 适合网络播放 + + // 输出路径 + savePath + }; + + // 执行FFmpeg合并命令 + await ExecuteFFmpegAsync(arguments, progress, cancellationToken); + + // 验证输出文件 + if (File.Exists(savePath)) + { + return savePath; + } + else + { + throw new InvalidOperationException("视频合并失败,未生成输出文件。"); + } + } + finally + { + // 清理临时文件 + if (File.Exists(tempListFile)) + { + File.Delete(tempListFile); + } + } + } /// /// 异步执行FFmpeg命令 diff --git a/utils/NfoFileGenerator.cs b/utils/NfoFileGenerator.cs index 40b18c2..5eb3951 100644 --- a/utils/NfoFileGenerator.cs +++ b/utils/NfoFileGenerator.cs @@ -1,4 +1,6 @@ using dy.net.dto; +using dy.net.model; +using System.IO; using System.Text; using System.Xml.Linq; @@ -10,7 +12,69 @@ namespace dy.net.utils public class NfoFileGenerator { - public static void GenerateNfoFile(DouyinVideoNfo videoInfo, string filePath) + + /// + /// 生成NFO文件 + /// NFO文件包含视频的元数据信息,如标题、作者、封面等 + /// + /// 视频信息 + /// 一个表示异步操作的任务 + public static void GenerateVideoNfoFile(DouyinVideo video) + { + try + { + + string videoDirectory = Path.GetDirectoryName(video.VideoSavePath); // 视频所在目录 + string videoFileNameWithoutExt = Path.GetFileNameWithoutExtension(video.VideoSavePath); // 无扩展名的文件名 + string nfoFullPath = Path.Combine(videoDirectory, $"{videoFileNameWithoutExt}.nfo"); // NFO文件完整路径 + string postFullPath = Path.Combine(videoDirectory, "poster.jpg"); // NFO文件完整路径 + + if (!string.IsNullOrWhiteSpace(video.AuthorAvatar)) + { + if (!video.OnlyImgOrOnlyMp3) + { + //说明是视频 + //复制作者头像到当前目录 并改名为跟nfo里面的作者相同的名字 + if (File.Exists(video.AuthorAvatar)) + { + var fileExt = Path.GetExtension(video.AuthorAvatar); + + var nfoActorFullPath = Path.Combine(videoDirectory, $"{video.Author}{fileExt}"); + + if (File.Exists(nfoActorFullPath)) + { + File.Delete(nfoActorFullPath); + } + // 执行复制(CopyTo支持覆盖,但先删除更可控) + File.Copy(video.AuthorAvatar, nfoActorFullPath, overwrite: true); + } + } + } + + GenerateNfoFile(new DouyinVideoNfo + { + Actors = new List + { + new() { + Name = video.Author, + Role = "主演", + } + }, + Author = video.Author, + Poster = postFullPath, + Title = video.VideoTitle, + Thumbnail = postFullPath,// 使用poster作为缩略图 + ReleaseDate = video.CreateTime, + Genres = new List { video.Tag1, video.Tag2, video.Tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList() + }, nfoFullPath); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "{f}nfo文件生成异常", video.VideoTitle); + } + } + + private static void GenerateNfoFile(DouyinVideoNfo videoInfo, string filePath) { try { @@ -22,6 +86,10 @@ namespace dy.net.utils // 创建根元素 XElement root = new XElement("movie"); + root.Add(new XElement("outline")); + root.Add(new XElement("lockdata", true)); + root.Add(new XElement("director", videoInfo.Author)); + root.Add(new XElement("plot", $"")); // 添加视频信息(先清理无效字符) if (!string.IsNullOrWhiteSpace(videoInfo.Title)) @@ -32,7 +100,10 @@ namespace dy.net.utils // 发布时间(无需清理,因为是格式化的日期字符串) if (videoInfo.ReleaseDate.HasValue) + { root.Add(new XElement("releasedate", videoInfo.ReleaseDate.Value.ToString("yyyy-MM-dd"))); + root.Add(new XElement("premiered", videoInfo.ReleaseDate.Value.ToString("yyyy-MM-dd"))); + } // 分类标签(清理每个标签) if (videoInfo.Genres != null && videoInfo.Genres.Any()) @@ -47,7 +118,6 @@ namespace dy.net.utils // --- 新增:处理演员信息 --- if (videoInfo.Actors != null && videoInfo.Actors.Any()) { - var actorsElement = new XElement("actors"); foreach (var actor in videoInfo.Actors) { // 至少需要演员姓名 @@ -55,22 +125,14 @@ namespace dy.net.utils { var actorElement = new XElement("actor"); actorElement.Add(new XElement("name", CleanInvalidXmlChars(actor.Name))); - - // 可选的角色和头像 if (!string.IsNullOrWhiteSpace(actor.Role)) actorElement.Add(new XElement("role", CleanInvalidXmlChars(actor.Role))); - if (!string.IsNullOrWhiteSpace(actor.Thumb)) - actorElement.Add(new XElement("thumb", CleanInvalidXmlChars(actor.Thumb))); + actorElement.Add(new XElement("tmdbid", "3141592610000"));//写死一个反正不存在的ID,防止被媒体管理软件误认 - actorsElement.Add(actorElement); + root.Add(actorElement); } } - // 将整个 节点添加到根节点 - if (actorsElement.HasElements) - { - root.Add(actorsElement); - } } // --- 演员信息处理结束 ---
{{ logs }}