修复动态视频多个小动态图视频 合成失败bug

其他优化
This commit is contained in:
jianzhichu
2026-01-13 22:14:50 +08:00
parent e32d783de4
commit 0727999cd6
10 changed files with 291 additions and 162 deletions
@@ -3,7 +3,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl> <_PublishTargetUrl>E:\code\dysync\bin\Release\net6.0\publish\</_PublishTargetUrl>
<History>True|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||;</History> <History>True|2026-01-13T14:03:06.4683237Z||;False|2026-01-13T22:03:01.2036027+08:00||;True|2026-01-13T21:36:05.8776209+08:00||;True|2026-01-13T21:33:02.0766289+08:00||;False|2026-01-13T21:32:57.1976141+08:00||;True|2026-01-13T19:14:35.2519439+08:00||;True|2026-01-13T15:07:50.0836745+08:00||;False|2026-01-13T15:06:35.5929685+08:00||;True|2026-01-13T10:21:41.6012776+08:00||;True|2026-01-13T09:57:25.4788847+08:00||;True|2026-01-13T09:53:14.4900360+08:00||;True|2026-01-13T09:23:53.6465295+08:00||;True|2026-01-12T22:04:14.5886955+08:00||;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||;</History>
<LastFailureDetails /> <LastFailureDetails />
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+75 -20
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
// 原有代码不变,此处省略(仅保留模板相关修改,脚本逻辑无变化)
import { getBase64 } from '@/utils/file'; import { getBase64 } from '@/utils/file';
import { FormInstance } from 'ant-design-vue'; import { FormInstance } from 'ant-design-vue';
import { reactive, ref, onMounted, UnwrapRef, watch } from 'vue'; import { reactive, ref, onMounted, UnwrapRef, watch } from 'vue';
@@ -320,49 +321,50 @@ const downImgVideo = ref(true);
<a-form-item label="我的secUserId" name="secUserId"> <a-form-item label="我的secUserId" name="secUserId">
<div style="display: flex; align-items: center; gap: 6px;"> <div style="display: flex; align-items: center; gap: 6px;">
<a-input v-model:value="form.secUserId" style="flex: 1;" /> <a-input v-model:value="form.secUserId" style="flex: 1;" placeholder="如果要同步“我喜欢”的视频和关注列表时,必填!!!" />
<a-tooltip title="同步“我喜欢的”视频时,必填!!!"> <a-tooltip title="如果要同步“我喜欢”的视频和关注列表时,必填!!!">
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" /> <ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
</a-tooltip> </a-tooltip>
</div> </div>
</a-form-item> </a-form-item>
<!-- 收藏的存储路径 -->
<a-form-item label="收藏的存储路径" name="savePath"> <a-form-item label="收藏的存储路径" name="savePath">
<div style="display: flex; align-items: center; gap: 6px;"> <div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.savePath" /> <a-input v-model:value="form.savePath" style="width: 200px;" />
<a-alert message="不想同步收藏的视频就空着" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
</div> </div>
</a-form-item> </a-form-item>
<!-- 新增是否全部用一个地址开关 --> <!-- 新增是否全部用一个地址开关 -->
<a-form-item label="统一存储路径" name="useSinglePath"> <a-form-item label="统一存储路径" name="useSinglePath">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<a-switch v-model:checked="form.useSinglePath" :checked-value="true" :un-checked-value="false" size="default" /> <a-switch v-model:checked="form.useSinglePath" :checked-value="true" :un-checked-value="false" size="default" />
<span>{{ form.useSinglePath ? '开启(共用收藏视频路径,如果是容器部署-docker-compose只需要映射一个路径就行了' : '关闭(各路径独立配置' }}</span> <span>{{ form.useSinglePath ? '共用收藏视频路径,如果是容器部署-docker-compose配置,只需要映射一个路径就行了' : '各类视频路径分开独立配置' }}</span>
</div> </div>
</a-form-item> </a-form-item>
<!-- 喜欢的存储路径 -->
<a-form-item label="喜欢的存储路径" name="favSavePath"> <a-form-item label="喜欢的存储路径" name="favSavePath">
<div style="display: flex; align-items: center; gap: 6px;"> <div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.favSavePath" :disabled="form.useSinglePath" placeholder="开启统一存储路径模式后将自动同步收藏路径的值" /> <a-input v-model:value="form.favSavePath" :disabled="form.useSinglePath" placeholder="" style="width: 200px;" />
<a-tooltip title="同步“我喜欢的视频时,必填!!!"> <a-alert message="不想同步喜欢的视频就空着" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
</a-tooltip>
</div> </div>
</a-form-item> </a-form-item>
<!-- 关注的存储路径 -->
<a-form-item label="关注的存储路径" name="upSavePath"> <a-form-item label="关注的存储路径" name="upSavePath">
<div style="display: flex; align-items: center; gap: 6px;"> <div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.upSavePath" :disabled="form.useSinglePath" placeholder="开启统一存储路径模式后将自动同步收藏路径的值" style="flex: 1;" /> <a-input v-model:value="form.upSavePath" :disabled="form.useSinglePath" placeholder="开启统一存储路径模式后将自动同步收藏路径的值" style="width: 200px;" />
<a-tooltip title="同步指定博主视频时必填!!!"> <a-alert message="不想同步关注列表博主视频就空着" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
</a-tooltip>
</div> </div>
</a-form-item> </a-form-item>
<!-- 图文的存储路径 -->
<a-form-item label="图文的存储路径" name="imgSavePath"> <a-form-item label="图文的存储路径" name="imgSavePath">
<div style="display: flex; align-items: center; gap: 6px;"> <div style="display: flex; align-items: center; gap: 12px; width: 100%;">
<a-input v-model:value="form.imgSavePath" :disabled="form.useSinglePath" placeholder="开启统一存储路径模式后将自动同步收藏路径的值" style="flex: 1;" /> <a-input v-model:value="form.imgSavePath" :disabled="form.useSinglePath" placeholder="开启统一存储路径模式后将自动同步收藏路径的值" style="width: 200px;" />
<a-tooltip title="同步图文视频必填!!!"> <a-alert message="如果系统配置页面开启了同步图文视频,且开启了单独存储,则必填!!!" type="info" size="small" style="flex: 1; margin-bottom: 0;" />
<ExclamationCircleOutlined style="color: #faad14;font-size: 16px;" />
</a-tooltip>
</div> </div>
</a-form-item> </a-form-item>
@@ -522,4 +524,57 @@ const downImgVideo = ref(true);
background-color: #f5f5f5 !important; background-color: #f5f5f5 !important;
color: #666 !important; color: #666 !important;
} }
/* 修复:移除对alert的自定义样式修改,还原官方默认样式 */
/* 删除了原有的ant-alert-small和ant-alert-message自定义样式,避免覆盖官方主题 */
/* 如需调整alert间距,仅修改外层容器,不修改alert内部样式 */
.alert-wrapper {
flex: 1;
margin-bottom: 0 !important;
}
</style>
<!-- 新增全局样式块确保alert样式不受scoped影响仅针对alert还原默认 -->
<style>
/* 还原Ant Design Vue alert官方默认样式,消除scoped样式的间接影响 */
.ant-alert {
box-sizing: border-box;
margin: 0;
/* padding: 16px 16px; */
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
line-height: 1.5714285714;
list-style: none;
position: relative;
display: flex;
align-items: center;
border-radius: 8px;
}
.ant-alert-small {
padding: 8px 16px;
font-size: 12px;
border-radius: 4px;
}
/* 还原alert不同类型的官方默认颜色 */
.ant-alert-info {
background-color: #e6f4ff;
border: 1px solid #91caff;
}
.ant-alert-info .ant-alert-message {
color: #1677ff;
}
.ant-alert-warning {
background-color: #fffbe6;
border: 1px solid #ffe58f;
}
.ant-alert-warning .ant-alert-message {
color: #faad14;
}
/* 确保alert图标颜色同步官方样式 */
.ant-alert-info .ant-alert-icon {
color: #1677ff;
}
.ant-alert-warning .ant-alert-icon {
color: #faad14;
}
</style> </style>
+1 -1
View File
@@ -76,7 +76,7 @@
<template v-if="item.isEditing"> <template v-if="item.isEditing">
<div class="edit-input-group"> <div class="edit-input-group">
<!-- 输入框禁用item.isSaving true --> <!-- 输入框禁用item.isSaving true -->
<a-input v-model:value="item.savePath" placeholder="请输入文件夹名称" @keypress.enter="() => handleSavePath(item)" maxlength="10" :disabled="item.isSaving" /> <a-input v-model:value="item.savePath" placeholder="请输入文件夹名称" @keypress.enter="() => handleSavePath(item)" maxlength="30" :disabled="item.isSaving" />
<!-- 保存按钮禁用item.isSaving true --> <!-- 保存按钮禁用item.isSaving true -->
<a-button type="text" class="edit-btn" @click="() => handleSavePath(item)" :disabled="item.isSaving"> <a-button type="text" class="edit-btn" @click="() => handleSavePath(item)" :disabled="item.isSaving">
<SaveOutlined /> <SaveOutlined />
+19 -35
View File
@@ -5,23 +5,23 @@
<div class="form-section"> <div class="form-section">
<h3 class="section-title">任务调度</h3> <h3 class="section-title">任务调度</h3>
<a-form-item has-feedback label="同步周期(分钟)" name="Cron" :wrapper-col="{ span: 20 }" style="margin-left: 30px"> <a-form-item has-feedback label="同步周期" name="Cron" :wrapper-col="{ span: 20 }">
<a-input-number v-model:value="formState.Cron" placeholder="请输入数字" :min="15" /> <a-input-number v-model:value="formState.Cron" placeholder="请输入数字" :min="15" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>同步任务执行间隔最小15分钟建议使用默认值</span> <span>同步任务执行间隔分钟最小15分钟建议使用默认值</span>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item has-feedback label="每次同步上限" name="BatchCount" :wrapper-col="{ span: 20 }" style="margin-left: 30px"> <a-form-item has-feedback label="单次最多" name="BatchCount" :wrapper-col="{ span: 20 }">
<a-input-number v-model:value="formState.BatchCount" placeholder="请输入每次下载数量上限(最大30)" :min="10" :max="30" /> <a-input-number v-model:value="formState.BatchCount" placeholder="请输入每次下载数量上限(最大30)" :min="10" :max="30" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>每次最大下载数量10-30</span> <span>每次任务最多下载数量10-30拉取视频数据超出该值将会等待下次任务执行</span>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item has-feedback label="仅同步最新视频" name="OnlySyncNew" :wrapper-col="{ span: 20 }"> <a-form-item has-feedback label="仅新视频" name="OnlySyncNew" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.OnlySyncNew" /> <a-switch v-model:checked="formState.OnlySyncNew" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
@@ -34,24 +34,24 @@
<div class="form-section"> <div class="form-section">
<h3 class="section-title">博主视频</h3> <h3 class="section-title">博主视频</h3>
<a-form-item has-feedback label="标题当文件名" name="UperUseViedoTitle" :wrapper-col="{ span: 20 }"> <a-form-item has-feedback label="默认规则" name="UperUseViedoTitle" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.UperUseViedoTitle" /> <a-switch v-model:checked="formState.UperUseViedoTitle" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>开启后用原标题作为文件名开启但又没设置标题规则模板则默认用视频id命名</span> <span>开启后将使用原标题作为文件名开启且未设置标题规则模板则默认用视频id命名</span>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item has-feedback label="不创建子目录" name="UperSaveTogether" :wrapper-col="{ span: 20 }"> <a-form-item has-feedback label="统一存储" name="UperSaveTogether" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.UperSaveTogether" /> <a-switch v-model:checked="formState.UperSaveTogether" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>默认按博主名建文件夹开启后直接存映射目录根目录</span> <span>开启后所有博主视频直接都存一个根目录关闭后按博主名称创建文件夹单独存储</span>
</div> </div>
</a-form-item> </a-form-item>
<!-- 模板相关配置只有关闭"标题当文件名"时才显示 --> <!-- 模板相关配置只有关闭"标题当文件名"时才显示 -->
<a-form-item has-feedback label="定义标题模板" name="FollowedTitleTemplate" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle"> <a-form-item has-feedback label="标题模板" name="FollowedTitleTemplate" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle">
<a-select v-model:value="formState.FollowedTitleTemplate" :options="template_options" mode="multiple" size="middle" placeholder="请选择占位符组合"></a-select> <a-select v-model:value="formState.FollowedTitleTemplate" :options="template_options" mode="multiple" size="middle" placeholder="请选择占位符组合"></a-select>
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<p></p> <p></p>
@@ -62,27 +62,19 @@
</div> </div>
</a-form-item> </a-form-item>
<a-form-item has-feedback label="模板分隔符" name="FollowedTitleSeparator" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle"> <a-form-item has-feedback label="分隔符" name="FollowedTitleSeparator" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle">
<a-input v-model:value="formState.FollowedTitleSeparator" placeholder="请输入分隔符(如 - _ 或空)" style="width: 200px" /> <a-input v-model:value="formState.FollowedTitleSeparator" placeholder="请输入分隔符(如 - _ 或空)" style="width: 200px" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span> <span>
占位符之间的连接符-_.为空则直接拼接<br /> 占位符之间的连接符-_.为空则直接拼接,不要使用无法用来作为文件名得特殊字符
<strong class="text-gray-700">示例</strong>选择{Author}{VideoTitle}分隔符填-将生成博主名称-视频标题格式文件名
</span> </span>
</div> </div>
</a-form-item> </a-form-item>
<!-- 完整模板预览只有关闭"标题当文件名"时才显示 --> <!-- 完整模板预览只有关闭"标题当文件名"时才显示 -->
<a-form-item label="完整模板预览" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle"> <a-form-item label="标题预览" :wrapper-col="{ span: 12 }" v-if="!formState.UperUseViedoTitle">
<a-input v-model:value="formState.FullFollowedTitleTemplate" placeholder="最终的模板" disabled style="background: #f5f5f5" /> <a-input v-model:value="formState.FullFollowedTitleTemplate" placeholder="自定义标题预览" disabled style="background: #f5f5f5" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>
实时展示您选择的占位符和分隔符组合效果<br />
<strong class="text-gray-700">示例</strong>选择{Author}{ReleaseTime}{Id}分隔符填_预览结果为博主名称_20250101_123456
</span>
</div>
</a-form-item> </a-form-item>
</div> </div>
@@ -126,7 +118,7 @@
<div class="audio-upload-player-wrapper" style="display: flex; align-items: center; gap: 16px;"> <div class="audio-upload-player-wrapper" style="display: flex; align-items: center; gap: 16px;">
<a-upload :before-upload="beforeUpload" :custom-request="customUpload" :show-upload-list="false" accept=".mp3,.wav"> <a-upload :before-upload="beforeUpload" :custom-request="customUpload" :show-upload-list="false" accept=".mp3,.wav">
<a-button type="default"> <a-button type="default">
<UploadOutlined /> 选择默认音频文件 <UploadOutlined /> 选择文件
</a-button> </a-button>
</a-upload> </a-upload>
@@ -156,22 +148,14 @@
<a-switch v-model:checked="formState.DownDynamicVideo" /> <a-switch v-model:checked="formState.DownDynamicVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>针对有些视频是多个视频生成的实际是分为多个视频开启后将会分别下载多个视频名字带_001,002这样</span> <span>开启后将下载动态视频多个短的小视频拼接-合成</span>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item v-if="formState.DownDynamicVideo" has-feedback label="合并动态视频" name="MegDynamicVideo" :wrapper-col="{ span: 20 }"> <a-form-item v-if="formState.DownDynamicVideo" has-feedback label="保留原视频" name="KeepDynamicVideo" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.MegDynamicVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>开启后将多个动态视频会合并为一个视频</span>
</div>
</a-form-item>
<a-form-item v-if="formState.MegDynamicVideo" has-feedback label="保留原视频" name="KeepDynamicVideo" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.KeepDynamicVideo" /> <a-switch v-model:checked="formState.KeepDynamicVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>开启后将保留合成视频之前的每个短视频</span> <span>开启后将保留合成视频之前的每个短视频动态视频会有多个独立的短视频文件可以选择保留-但不建议否则emby会出现很多没有封面的视频...</span>
</div> </div>
</a-form-item> </a-form-item>
</div> </div>
@@ -180,7 +164,7 @@
<div class="form-section"> <div class="form-section">
<h3 class="section-title">视频去重</h3> <h3 class="section-title">视频去重</h3>
<a-form-item v-show="formState.AutoDistinct" has-feedback label="去重优先级" name="PriorityLevel" :wrapper-col="{ span: 20 }"> <a-form-item v-show="formState.AutoDistinct" has-feedback label="去重优先级" name="PriorityLevel" :wrapper-col="{ span: 20 }">
<!-- Tag 拖拽容器 --> <!-- Tag 拖拽容器 -->
<div class="tag-drag-container"> <div class="tag-drag-container">
<!-- Tag 拖拽容器绑定 ref Sortable 初始化 --> <!-- Tag 拖拽容器绑定 ref Sortable 初始化 -->
+54 -48
View File
@@ -140,7 +140,7 @@ namespace dy.net.job
Log.Debug($"{VideoType}-未获取到系统配置,任务终止!!!"); Log.Debug($"{VideoType}-未获取到系统配置,任务终止!!!");
return; return;
} }
// 2. 从配置中获取每页请求数量--固定18 // 2. 从配置中获取每页请求数量--固定18
//if (config.BatchCount > 0) //if (config.BatchCount > 0)
@@ -260,7 +260,7 @@ namespace dy.net.job
/// <param name="syncCount">本次同步成功的视频数量</param> /// <param name="syncCount">本次同步成功的视频数量</param>
/// <param name="followed"></param> /// <param name="followed"></param>
/// <returns>一个表示异步操作的任务</returns> /// <returns>一个表示异步操作的任务</returns>
protected abstract Task HandleSyncCompletion(DouyinCookie cookie, int syncCount,DouyinFollowed followed=null); protected abstract Task HandleSyncCompletion(DouyinCookie cookie, int syncCount, DouyinFollowed followed = null);
/// <summary> /// <summary>
/// 获取视频实体的差异信息 /// 获取视频实体的差异信息
@@ -308,7 +308,7 @@ namespace dy.net.job
Log.Debug($"{VideoType}- Cookie-[{cookie.UserName}]开始同步..."); Log.Debug($"{VideoType}- Cookie-[{cookie.UserName}]开始同步...");
//up主上传视频特殊处理 //up主上传视频特殊处理
if (VideoType ==VideoTypeEnum.dy_follows) if (VideoType == VideoTypeEnum.dy_follows)
{ {
int syncCount = 0; // 本次同步成功的视频数量 int syncCount = 0; // 本次同步成功的视频数量
string cursor = "0"; // 初始游标 string cursor = "0"; // 初始游标
@@ -335,7 +335,7 @@ namespace dy.net.job
await GetViedos(cookie, config, syncCount, cursor, hasMore, followed); await GetViedos(cookie, config, syncCount, cursor, hasMore, followed);
hasMore = true; hasMore = true;
// 处理同步完成后的操作 // 处理同步完成后的操作
await HandleSyncCompletion(cookie, syncCount,followed); await HandleSyncCompletion(cookie, syncCount, followed);
} }
} }
else else
@@ -387,7 +387,7 @@ namespace dy.net.job
// 5. 检查批次上限,若满足条件则终止循环(核心优化:抽离为方法) // 5. 检查批次上限,若满足条件则终止循环(核心优化:抽离为方法)
if (IsSyncLimitReached(cookie, config, syncCount)) if (IsSyncLimitReached(cookie, config, syncCount))
{ {
break; break;
} }
//随机等待 //随机等待
await Task.Delay(_random.Next(5, 10) * 1000); await Task.Delay(_random.Next(5, 10) * 1000);
@@ -414,7 +414,7 @@ namespace dy.net.job
return false; return false;
} }
// 获取当前视频类型对应的状态判断逻辑 // 获取当前视频类型对应的状态判断逻辑
if (!_syncStatusCheckMap.TryGetValue(VideoType, out var isSyncCompleted)) if (!_syncStatusCheckMap.TryGetValue(VideoType, out var isSyncCompleted))
{ {
@@ -445,7 +445,6 @@ namespace dy.net.job
var videos = new List<DouyinVideo>(); var videos = new List<DouyinVideo>();
foreach (var item in data.AwemeList) foreach (var item in data.AwemeList)
{ {
//if (!item.Desc.Contains("人生苦短"))
//if (item.AwemeId!= "7202236830337551616") //if (item.AwemeId!= "7202236830337551616")
//{ //{
// continue; // continue;
@@ -469,7 +468,8 @@ namespace dy.net.job
continue; continue;
} }
//如果存在,但是因为去重规则,选择的最高优先级变更了,需要删除原来的,重新下载。 //如果存在,但是因为去重规则,选择的最高优先级变更了,需要删除原来的,重新下载。
if (exitVideo != null) { if (exitVideo != null)
{
await douyinVideoService.DeleteById(exitVideo.Id); await douyinVideoService.DeleteById(exitVideo.Id);
} }
var uper = await douyinFollowService.GetByUperId(item.AuthorUserId.ToString(), cookie.MyUserId); var uper = await douyinFollowService.GetByUperId(item.AuthorUserId.ToString(), cookie.MyUserId);
@@ -484,7 +484,8 @@ namespace dy.net.job
var video = await ProcessSingleVideo(cookie, item, data, config, followed); var video = await ProcessSingleVideo(cookie, item, data, config, followed);
if (video != null) if (video != null)
videos.Add(video); videos.Add(video);
else{ else
{
//处理多个视频-组合的图文视频--类似动图。 //处理多个视频-组合的图文视频--类似动图。
List<string> dynamicVideoUrls = new List<string>(); List<string> dynamicVideoUrls = new List<string>();
@@ -512,30 +513,35 @@ namespace dy.net.job
var dynamicVideo = await ProcessDynamicVideo(dynamicVideoUrls, cookie, item, data, config); var dynamicVideo = await ProcessDynamicVideo(dynamicVideoUrls, cookie, item, data, config);
if (dynamicVideo != null) if (dynamicVideo != null)
{ {
if(!string.IsNullOrEmpty(dynamicVideo.DynamicVideos)) if (!string.IsNullOrEmpty(dynamicVideo.DynamicVideos))
{ {
var dynamicVideos = JsonConvert.DeserializeObject<List<string>>(dynamicVideo.DynamicVideos); var dynamicVideos = JsonConvert.DeserializeObject<List<string>>(dynamicVideo.DynamicVideos);
Log.Debug($"{VideoType}-动态视频[{item.Desc}],下载成功 ,共{dynamicVideos?.Count}个视频..."); Log.Debug($"{VideoType}-动态视频[{item.Desc}],下载成功 ,共{dynamicVideos?.Count}个视频...");
if (config.MegDynamicVideo) if (dynamicVideos != null && dynamicVideos.Count > 0)
{ {
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 mp3Url= item?.Music?.PlayUrl?.UrlList?.FirstOrDefault();
var outPath = await douyinMergeVideoService.MergeMultipleVideosAsync(dynamicVideos,mp3Url, savePath,cookie.Cookies, width, height);
if(!string.IsNullOrWhiteSpace(outPath))
{ {
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)) if (File.Exists(outPath))
{ {
dynamicVideo.VideoSavePath = outPath; dynamicVideo.VideoSavePath = outPath;
if (!config.KeepDynamicVideo) if (!config.KeepDynamicVideo)
{ {
//不保留原视频-删除
foreach (var opath in dynamicVideos) foreach (var opath in dynamicVideos)
{ {
if (File.Exists(opath)) if (File.Exists(opath))
@@ -543,13 +549,10 @@ namespace dy.net.job
} }
} }
} }
} }
} }
} }
videos.Add(dynamicVideo); videos.Add(dynamicVideo);
} }
else else
{ {
@@ -571,7 +574,7 @@ namespace dy.net.job
return videos; return videos;
} }
private async Task<bool> AutoDistinct(AppConfig config, Aweme item,DouyinCookie cookie,DouyinVideo exitVideo) private async Task<bool> AutoDistinct(AppConfig config, Aweme item, DouyinCookie cookie, DouyinVideo exitVideo)
{ {
// 去重,检查视频是否已存在(按优先级下载) // 去重,检查视频是否已存在(按优先级下载)
if (config.AutoDistinct) if (config.AutoDistinct)
@@ -786,7 +789,7 @@ namespace dy.net.job
return null; return null;
} }
Log.Debug($"{VideoType}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc,item.AwemeId)}]开始下载..."); Log.Debug($"{VideoType}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]开始下载...");
// 随机延迟 // 随机延迟
await Task.Delay(_random.Next(1, 4) * 1000); await Task.Delay(_random.Next(1, 4) * 1000);
@@ -795,7 +798,7 @@ namespace dy.net.job
if (!Success) if (!Success)
{ {
Log.Error($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]下载失败!!!"); Log.Error($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]下载失败!!!");
(bool flowControl, DouyinVideo value) = await SwitchOtherUrlAddressDown(cookie, item, videoUrl, savePath); (bool flowControl, DouyinVideo value) = await SwitchOtherUrlAddressDown(cookie, item, videoUrl, savePath);
if (!flowControl) if (!flowControl)
{ {
@@ -810,25 +813,27 @@ namespace dy.net.job
await DownVideoCover(item, saveFolder, cookie, config); await DownVideoCover(item, saveFolder, cookie, config);
// 下载作者头像 // 下载作者头像
var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item); var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item);
// 创建视频实体 // 创建视频实体
return await 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);
} }
protected async Task<DouyinVideo> ProcessDynamicVideo(List<string> dynamicUrls,DouyinCookie cookie, Aweme item, DouyinVideoInfo data, AppConfig config, DouyinFollowed followed = null) protected async Task<DouyinVideo> ProcessDynamicVideo(List<string> dynamicUrls, DouyinCookie cookie, Aweme item, DouyinVideoInfo data, AppConfig config, DouyinFollowed followed = null)
{ {
// 获取视频标签 // 获取视频标签
var (tag1, tag2, tag3) = GetVideoTags(item); var (tag1, tag2, tag3) = GetVideoTags(item);
// 创建保存文件夹 // 创建保存文件夹
var saveFolder = CreateSaveFolder(cookie, item, config, followed); var saveFolder = CreateSaveFolder(cookie, item, config, followed);
// 获取视频文件名 // 获取视频文件名
var fileName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)+".mp4"; //var fileName = DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId) + ".mp4";
var fileName = $"{item.AwemeId}.mp4";
// 拼接视频保存路径 // 拼接视频保存路径
var savePath = Path.Combine(saveFolder, fileName); var savePath = Path.Combine(saveFolder, fileName);
@@ -841,6 +846,7 @@ namespace dy.net.job
{ {
// 下载动态视频 // 下载动态视频
var dynamicSavePath = savePath.Replace(".mp4", $"_00{i}.mp4"); var dynamicSavePath = savePath.Replace(".mp4", $"_00{i}.mp4");
i++;
// 如果文件已存在,跳过 // 如果文件已存在,跳过
if (File.Exists(dynamicSavePath)) if (File.Exists(dynamicSavePath))
@@ -852,13 +858,12 @@ namespace dy.net.job
var (Success, ActualSavePath) = await douyinHttpClientService.DownloadAsync(dynamicUrl, dynamicSavePath, cookie.Cookies); var (Success, ActualSavePath) = await douyinHttpClientService.DownloadAsync(dynamicUrl, dynamicSavePath, cookie.Cookies);
if (!Success) if (!Success)
{ {
Log.Error($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]-00{i},下载失败!!!"); Log.Error($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{dynamicSavePath}]-00{i},下载失败!!!");
} }
else else
{ {
dynamicSavePaths.Add(dynamicSavePath); dynamicSavePaths.Add(dynamicSavePath);
i++; Log.Debug($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{dynamicSavePath}]-00{i},下载完成.");
Log.Debug($"{VideoType}-{item?.Author?.Nickname ?? ""}-视频[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]-00{i},下载完成.");
} }
await Task.Delay(_random.Next(1, 4) * 1000); await Task.Delay(_random.Next(1, 4) * 1000);
} }
@@ -881,11 +886,11 @@ namespace dy.net.job
DataSize = DouyinFileUtils.GetTotalFileSize(dynamicSavePaths) // 合成视频的文件大小 DataSize = DouyinFileUtils.GetTotalFileSize(dynamicSavePaths) // 合成视频的文件大小
} }
}; };
return await 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);
} }
@@ -929,7 +934,7 @@ namespace dy.net.job
{ {
Log.Debug($"{VideoType}-额外找到{otherUrls.Count}个视频地址,即将再次开始下载..."); Log.Debug($"{VideoType}-额外找到{otherUrls.Count}个视频地址,即将再次开始下载...");
var (Success, ActualSavePath) = await douyinHttpClientService.DownloadAsync(videoUrl, savePath, cookie.Cookies, otherUrls); var (Success, ActualSavePath) = await douyinHttpClientService.DownloadAsync(videoUrl, savePath, cookie.Cookies, otherUrls);
if(Success) if (Success)
{ {
Log.Debug($"{VideoType}-尝试多个地址后,下载成功,{savePath}"); Log.Debug($"{VideoType}-尝试多个地址后,下载成功,{savePath}");
} }
@@ -1028,7 +1033,7 @@ namespace dy.net.job
var mergeResult = await douyinMergeVideoService.MergeToVideo(cookie.Cookies, AppContext.BaseDirectory, reqParams, savePath, fileNamefolder, config.DownImageVideo, config.DownImage, config.DownMp3); var mergeResult = await douyinMergeVideoService.MergeToVideo(cookie.Cookies, AppContext.BaseDirectory, reqParams, savePath, fileNamefolder, config.DownImageVideo, config.DownImage, config.DownMp3);
if (!mergeResult) if (!mergeResult)
{ {
Log.Error($"{VideoType}-图文视频-[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc,item.AwemeId)}]合成失败!!!"); Log.Error($"{VideoType}-图文视频-[{DouyinFileNameHelper.SanitizeLinuxFileName(item.Desc, item.AwemeId)}]合成失败!!!");
return null; return null;
} }
@@ -1051,7 +1056,8 @@ namespace dy.net.job
return null; return null;
} }
} }
else { else
{
savePath = ""; savePath = "";
} }
@@ -1060,7 +1066,7 @@ namespace dy.net.job
await DownVideoCover(imageUrls.FirstOrDefault(), fileNamefolder, cookie, item, config); await DownVideoCover(imageUrls.FirstOrDefault(), fileNamefolder, cookie, item, config);
// 下载作者头像 // 下载作者头像
var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item); var (avatarSavePath, avatarUrl) = await DownAuthorAvatar(cookie, item);
// 获取视频标签 // 获取视频标签
var (tag1, tag2, tag3) = GetVideoTags(item); var (tag1, tag2, tag3) = GetVideoTags(item);
// 为合成的视频创建一个“虚拟”的BitRate对象,以便复用CreateVideoEntity方法 // 为合成的视频创建一个“虚拟”的BitRate对象,以便复用CreateVideoEntity方法
@@ -1075,14 +1081,14 @@ namespace dy.net.job
}; };
// 创建视频实体 // 创建视频实体
var videoEntity =await CreateVideoEntity(config, var videoEntity = await CreateVideoEntity(config,
cookie, item, virtualBitRate, savePath, fileNamefolder, cookie, item, virtualBitRate, savePath, fileNamefolder,
tag1, tag2, tag3, avatarSavePath, avatarUrl, data); tag1, tag2, tag3, avatarSavePath, avatarUrl, data);
// 特殊处理合成视频的字段 // 特殊处理合成视频的字段
videoEntity.FileHash = string.Empty; // 合成视频没有原始文件哈希 videoEntity.FileHash = string.Empty; // 合成视频没有原始文件哈希
videoEntity.VideoUrl = "/"; // 合成视频没有原始URL videoEntity.VideoUrl = "/"; // 合成视频没有原始URL
videoEntity.ViedoType = VideoType; videoEntity.ViedoType = VideoType;
videoEntity.IsMergeVideo = 1;// 标记为图片合成视频 videoEntity.IsMergeVideo = 1;// 标记为图片合成视频
return videoEntity; return videoEntity;
@@ -1117,7 +1123,7 @@ namespace dy.net.job
} }
} }
/// <summary> /// <summary>
/// 下载视频封面 /// 下载视频封面
@@ -1248,7 +1254,7 @@ namespace dy.net.job
// 如果封面文件不存在,则下载 // 如果封面文件不存在,则下载
if (!File.Exists(coverSavePath)) if (!File.Exists(coverSavePath))
{ {
await douyinHttpClientService.DownloadAsync(coverUrl, coverSavePath, cookie.Cookies); await douyinHttpClientService.DownloadAsync(coverUrl, coverSavePath, cookie.Cookies);
} }
} }
@@ -1272,7 +1278,7 @@ namespace dy.net.job
/// <returns>创建的视频实体对象</returns> /// <returns>创建的视频实体对象</returns>
private async Task<DouyinVideo> CreateVideoEntity(AppConfig config, private async Task<DouyinVideo> CreateVideoEntity(AppConfig config,
DouyinCookie cookie, Aweme item, VideoBitRate bitRate, string savePath, string saveFolder, DouyinCookie cookie, Aweme item, VideoBitRate bitRate, string savePath, string saveFolder,
string tag1, string tag2, string tag3, string avatarSavePath, string avatarUrl, DouyinVideoInfo data,List<string> dynamicVideos=null) string tag1, string tag2, string tag3, string avatarSavePath, string avatarUrl, DouyinVideoInfo data, List<string> dynamicVideos = null)
{ {
var diffs = GetVideoEntityDifferences(cookie, item); var diffs = GetVideoEntityDifferences(cookie, item);
+1 -1
View File
@@ -64,7 +64,7 @@ namespace dy.net.service
OnlySyncNew = true, OnlySyncNew = true,
DownDynamicVideo = false, DownDynamicVideo = false,
KeepDynamicVideo = false, KeepDynamicVideo = false,
MegDynamicVideo = false MegDynamicVideo = true
}; };
sqlSugarClient.Insertable(config).ExecuteCommand(); sqlSugarClient.Insertable(config).ExecuteCommand();
return config; return config;
+34 -11
View File
@@ -30,17 +30,48 @@ namespace dy.net.service
/// 多视频合成一个视频 /// 多视频合成一个视频
/// </summary> /// </summary>
/// <param name="videoFilePaths"></param> /// <param name="videoFilePaths"></param>
/// <param name="audioPath"></param>
/// <param name="savePath"></param> /// <param name="savePath"></param>
/// <param name="ck"></param>
/// <param name="width"></param> /// <param name="width"></param>
/// <param name="height"></param> /// <param name="height"></param>
/// <returns></returns> /// <returns></returns>
public async Task<string> MergeMultipleVideosAsync( public async Task<string> MergeMultipleVideosAsync(
List<string> videoFilePaths, List<string> videoFilePaths,
string audioPath,
string savePath, string savePath,
string ck,
int width = 1080, int width = 1080,
int height = 1920) int height = 1920)
{ {
return await new FFmpegHelper().MergeMultipleVideosAsync(videoFilePaths, savePath, width, height); string mp3FilePath = GetDefaultAudio();
if (!string.IsNullOrWhiteSpace(audioPath))
{
var (SuccessPaths, _) = await DownloadMediaAsync(new List<string> { audioPath }, Path.GetDirectoryName(savePath), "audio_", "mp3", ck);
if (SuccessPaths != null && SuccessPaths.Length > 0)
{
mp3FilePath = SuccessPaths[0];
}
}
var ffmpeg = new FFmpegHelper();
return await ffmpeg.MergeMultipleVideosAsync(videoFilePaths, mp3FilePath, savePath, width, height);
}
private static string GetDefaultAudio()
{
string mp3FilePath = 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))
{
mp3FilePath = uploadMp3;
}
return mp3FilePath;
} }
@@ -140,15 +171,7 @@ namespace dy.net.service
if (rawAudios.Length == 0) if (rawAudios.Length == 0)
{ {
var mp3Path = GetDefaultAudio();
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 }; rawAudios = new string[] { mp3Path };
Log.Debug("版权原因无法下载音频,使用默认无声音频文件"); Log.Debug("版权原因无法下载音频,使用默认无声音频文件");
} }
@@ -269,7 +292,7 @@ namespace dy.net.service
/// <summary> /// <summary>
/// 调整图片显示时长 /// 调整图片显示时长
/// </summary> /// </summary>
private void AdjustImageDuration(MediaMergeRequest request) private static void AdjustImageDuration(MediaMergeRequest request)
{ {
if (request.ImageUrls.Count <= 3) if (request.ImageUrls.Count <= 3)
request.ImageDurationPerSecond = 3; request.ImageDurationPerSecond = 3;
+13 -6
View File
@@ -28,7 +28,13 @@ namespace dy.net.utils
// 2. 过滤 Linux 非法字符: // 2. 过滤 Linux 非法字符:
// - 禁止:/(路径分隔符)、\0(空字符) // - 禁止:/(路径分隔符)、\0(空字符)
// - 替换:其他特殊字符(如 :*?"<>|\\ )为下划线 _,避免创建失败 // - 替换:其他特殊字符(如 :*?"<>|\\ )为下划线 _,避免创建失败
var invalidChars = new[] { '/', '\0', ':', '*', '?', '"', '<', '>', '|', '\\' }; //var invalidChars = new[] { '/', '\0', ':', '*', '?', '"', '<', '>', '|', '\\' };
var invalidChars = new[] {
'/', '\0', // Linux 核心非法字符
'\n', '\r', '\v', '\f', // 所有换行/回车/制表类控制字符
':', '*', '?', '"', '<', '>', '|', '\\' // 其他常见非法字符
};
string sanitizedName = originalName; string sanitizedName = originalName;
foreach (var c in invalidChars) foreach (var c in invalidChars)
{ {
@@ -82,19 +88,20 @@ namespace dy.net.utils
/// <returns>true:无特殊字符(仅允许字符);false:含有特殊字符</returns> /// <returns>true:无特殊字符(仅允许字符);false:含有特殊字符</returns>
public static bool IsValidWithoutSpecialChars(string input) public static bool IsValidWithoutSpecialChars(string input)
{ {
// 空字符串默认返回 true(若需禁止空字符串,可先判断 string.IsNullOrWhiteSpace(input) 并返回 false if (string.IsNullOrWhiteSpace(input))
if (string.IsNullOrEmpty(input))
return true; return true;
// 正则表达式说明: // 正则表达式说明:
// ^ :匹配字符串开头 // ^ :匹配字符串开头
// $ :匹配字符串结尾 // $ :匹配字符串结尾
// [a-zA-Z0-9\u4E00-\u9FA5] :允许的字符范围 // [a-zA-Z0-9\u4E00-\u9FA5 _] :允许的字符范围(新增空格和下划线)
// a-zA-Z:大小写字母 // a-zA-Z:大小写字母
// 0-9:数字 // 0-9:数字
// \u4E00-\u9FA5:简体中文 Unicode 核心范围(覆盖99%+简体中文常用字) // \u4E00-\u9FA5:简体中文 Unicode 核心范围
// 空格:普通空格字符
// _:下划线
// * :匹配 0 个或多个允许的字符(若需至少1个字符,可改为 +) // * :匹配 0 个或多个允许的字符(若需至少1个字符,可改为 +)
const string pattern = @"^[a-zA-Z0-9\u4E00-\u9FA5]*$"; const string pattern = @"^[a-zA-Z0-9\u4E00-\u9FA5 _]*$";
// 忽略文化差异,仅按字符编码匹配 // 忽略文化差异,仅按字符编码匹配
return Regex.IsMatch(input, pattern, RegexOptions.None); return Regex.IsMatch(input, pattern, RegexOptions.None);
+78 -27
View File
@@ -1,4 +1,5 @@
using System; using Serilog;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
@@ -13,7 +14,7 @@ namespace dy.net.utils
private string _ffmpegExecutablePath; private string _ffmpegExecutablePath;
private string _ffprobeExecutablePath ; private string _ffprobeExecutablePath;
public FFmpegHelper() public FFmpegHelper()
{ {
@@ -24,15 +25,15 @@ namespace dy.net.utils
} }
else else
{ {
#if DEBUG #if DEBUG
// Debug 环境,通常是 Windows // Debug 环境,通常是 Windows
_ffmpegExecutablePath = "E:\\down\\ffmpeg\\bin\\ffmpeg.exe"; _ffmpegExecutablePath = "E:\\down\\ffmpeg\\bin\\ffmpeg.exe";
_ffprobeExecutablePath = "E:\\down\\ffmpeg\\bin\\ffprobe.exe"; _ffprobeExecutablePath = "E:\\down\\ffmpeg\\bin\\ffprobe.exe";
#else #else
// Release 环境,通常是 Docker Linux // Release 环境,通常是 Docker Linux
_ffmpegExecutablePath = "ffmpeg"; _ffmpegExecutablePath = "ffmpeg";
_ffprobeExecutablePath = "ffprobe"; _ffprobeExecutablePath = "ffprobe";
#endif #endif
} }
} }
@@ -229,10 +230,12 @@ namespace dy.net.utils
} }
} }
/// <summary> /// <summary>
/// 合并多个视频文件为一个MP4视频 /// 合并多个视频文件为一个MP4视频(嵌入指定音频文件)
/// </summary> /// </summary>
/// <param name="videoFilePaths">待合并的视频路径列表(按合并顺序排列)</param> /// <param name="videoFilePaths">待合并的视频路径列表(按合并顺序排列)</param>
/// <param name="audioPath">音频文件(可为null/空,为空则保留视频原音频(若有))</param>
/// <param name="savePath">输出视频的保存路径</param> /// <param name="savePath">输出视频的保存路径</param>
/// <param name="width">输出视频宽度(自动修正为偶数)</param> /// <param name="width">输出视频宽度(自动修正为偶数)</param>
/// <param name="height">输出视频高度(自动修正为偶数)</param> /// <param name="height">输出视频高度(自动修正为偶数)</param>
@@ -241,6 +244,7 @@ namespace dy.net.utils
/// <returns>输出视频路径</returns> /// <returns>输出视频路径</returns>
public async Task<string> MergeMultipleVideosAsync( public async Task<string> MergeMultipleVideosAsync(
List<string> videoFilePaths, List<string> videoFilePaths,
string audioPath,
string savePath, string savePath,
int width = 1080, int width = 1080,
int height = 1920, int height = 1920,
@@ -260,6 +264,11 @@ namespace dy.net.utils
if (string.IsNullOrEmpty(savePath)) if (string.IsNullOrEmpty(savePath))
throw new ArgumentNullException(nameof(savePath)); throw new ArgumentNullException(nameof(savePath));
// 音频文件验证(若传入非空路径,则校验文件是否存在)
bool hasCustomAudio = !string.IsNullOrEmpty(audioPath) && File.Exists(audioPath);
//if (!string.IsNullOrEmpty(audioPath) && !hasCustomAudio)
// throw new FileNotFoundException("指定的音频文件未找到。", audioPath);
// 自动修正分辨率为偶数(H264编码要求) // 自动修正分辨率为偶数(H264编码要求)
if (width % 2 != 0) width++; if (width % 2 != 0) width++;
if (height % 2 != 0) height++; if (height % 2 != 0) height++;
@@ -282,49 +291,91 @@ namespace dy.net.utils
string escapedPath = videoPath.Replace("\\", "/").Replace("'", "\\'"); string escapedPath = videoPath.Replace("\\", "/").Replace("'", "\\'");
fileListContent.AppendLine($"file '{escapedPath}'"); fileListContent.AppendLine($"file '{escapedPath}'");
} }
File.WriteAllText(tempListFile, fileListContent.ToString(), Encoding.UTF8); File.WriteAllText(tempListFile, fileListContent.ToString(), new UTF8Encoding(false));
// 步骤2:构建FFmpeg合并参数 // 步骤2:构建FFmpeg合并+音频嵌入参数
var arguments = new List<string> var arguments = new List<string>();
{
"-y", // 覆盖输出文件
"-f", "concat", // 指定合并格式
"-safe", "0", // 允许访问绝对路径
"-i", tempListFile, // 输入文件列表
// 视频编码参数(复用现有类的编码配置,保证输出格式统一 // 核心修改:支持双输入(视频列表 + 自定义音频
if (hasCustomAudio)
{
// 输入1:视频文件列表(仅读取视频流,忽略原音频)
arguments.AddRange(new[]
{
"-y", // 覆盖输出文件
"-f", "concat",
"-safe", "0",
"-i", tempListFile,
// 输入2:自定义音频文件
"-i", audioPath,
// 映射流:输入0的视频流 → 输出视频流;输入1的音频流 → 输出音频流
"-map", "0:v",
"-map", "1:a",
// 音频同步:保证视频与音频时长匹配(若音频较短,循环音频;若较长,截断音频)
"-shortest", // 以较短的流时长为准,避免无意义的空帧/静音
"-filter:a", "apad=pad_len=0" // 音频补全,防止音频时长略短于视频导致的截断
});
}
else
{
// 无自定义音频:仅合并视频,保留原视频音频(若有)
arguments.AddRange(new[]
{
"-y", // 覆盖输出文件
"-f", "concat",
"-safe", "0",
"-i", tempListFile,
// 映射流:保留原视频的视频流和音频流
"-map", "0:v",
"-map", "0:a?", // "?" 表示若存在音频流则映射,不存在则忽略,避免报错
"-shortest"
});
}
// 视频编码参数(复用现有类的编码配置,保证输出格式统一)
arguments.AddRange(new[]
{
"-c:v", VideoCodec, "-c:v", VideoCodec,
"-preset", VideoPreset, "-preset", VideoPreset,
"-crf", $"{VideoCrf}", "-crf", $"{VideoCrf}",
"-s", $"{width}x{height}", // 统一输出分辨率 "-s", $"{width}x{height}", // 统一输出分辨率
"-pix_fmt", "yuv420p", // 兼容所有播放器 "-pix_fmt", "yuv420p", // 兼容所有播放器
"-profile:v", "main", "-profile:v", "main"
});
// 音频编码参数 // 音频编码参数(无论是否自定义音频,统一编码格式保证兼容性)
arguments.AddRange(new[]
{
"-c:a", AudioCodec, "-c:a", AudioCodec,
"-b:a", AudioBitrate, "-b:a", AudioBitrate,
"-ac", "2", // 立体声 "-ac", "2", // 立体声
"-ar", "44100", // 标准采样率 "-ar", "44100" // 标准采样率
});
// 封装优化 // 封装优化
arguments.AddRange(new[]
{
"-f", "mp4", "-f", "mp4",
"-movflags", "+faststart", // 适合网络播放 "-movflags", "+faststart" // 适合网络播放(将moov原子移至文件头部)
});
// 输出路径 // 输出路径
savePath arguments.Add(savePath);
};
// 执行FFmpeg合并命令 //Log.Error($"savePath={savePath}");
// 执行FFmpeg合并+音频嵌入命令
await ExecuteFFmpegAsync(arguments, progress, cancellationToken); await ExecuteFFmpegAsync(arguments, progress, cancellationToken);
// 验证输出文件 // 验证输出文件
if (File.Exists(savePath)) if (File.Exists(savePath))
{ {
Log.Debug($"视频合成成功:{savePath}");
return savePath; return savePath;
} }
else else
{ {
throw new InvalidOperationException("视频合失败,未生成输出文件。"); Log.Error("视频合失败,未生成输出文件。");
return "";
} }
} }
finally finally
+15 -12
View File
@@ -27,7 +27,7 @@ namespace dy.net.utils
string videoDirectory = Path.GetDirectoryName(video.VideoSavePath); // 视频所在目录 string videoDirectory = Path.GetDirectoryName(video.VideoSavePath); // 视频所在目录
string videoFileNameWithoutExt = Path.GetFileNameWithoutExtension(video.VideoSavePath); // 无扩展名的文件名 string videoFileNameWithoutExt = Path.GetFileNameWithoutExtension(video.VideoSavePath); // 无扩展名的文件名
string nfoFullPath = Path.Combine(videoDirectory, $"{videoFileNameWithoutExt}.nfo"); // NFO文件完整路径 string nfoFullPath = Path.Combine(videoDirectory, $"{videoFileNameWithoutExt}.nfo"); // NFO文件完整路径
string postFullPath = Path.Combine(videoDirectory, "poster.jpg"); // NFO文件完整路径 //string postFullPath = Path.Combine(videoDirectory, "poster.jpg"); // NFO文件完整路径
if (!string.IsNullOrWhiteSpace(video.AuthorAvatar)) if (!string.IsNullOrWhiteSpace(video.AuthorAvatar))
{ {
@@ -38,8 +38,12 @@ namespace dy.net.utils
if (File.Exists(video.AuthorAvatar)) if (File.Exists(video.AuthorAvatar))
{ {
var fileExt = Path.GetExtension(video.AuthorAvatar); var fileExt = Path.GetExtension(video.AuthorAvatar);
var actorsDir = Path.Combine(videoDirectory, ".actors");
var nfoActorFullPath = Path.Combine(videoDirectory, $"{video.Author}{fileExt}"); if(!Directory.Exists(actorsDir))
{
Directory.CreateDirectory(actorsDir);
}
var nfoActorFullPath = Path.Combine(actorsDir, $"{video.Author}{fileExt}");
if (File.Exists(nfoActorFullPath)) if (File.Exists(nfoActorFullPath))
{ {
@@ -61,9 +65,9 @@ namespace dy.net.utils
} }
}, },
Author = video.Author, Author = video.Author,
Poster = postFullPath, Poster = "poster.jpg",
Title = video.VideoTitle, Title = video.VideoTitle,
Thumbnail = postFullPath,// 使用poster作为缩略图 Thumbnail = "poster.jpg",// 使用poster作为缩略图
ReleaseDate = video.CreateTime, ReleaseDate = video.CreateTime,
Genres = new List<string> { video.Tag1, video.Tag2, video.Tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList() Genres = new List<string> { video.Tag1, video.Tag2, video.Tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
}, nfoFullPath); }, nfoFullPath);
@@ -86,19 +90,18 @@ namespace dy.net.utils
// 创建根元素 // 创建根元素
XElement root = new XElement("movie"); XElement root = new XElement("movie");
root.Add(new XElement("outline")); //root.Add(new XElement("outline"));
root.Add(new XElement("lockdata", true)); root.Add(new XElement("lockdata", true));
root.Add(new XElement("director", videoInfo.Author)); //root.Add(new XElement("director", videoInfo.Author));
root.Add(new XElement("plot", $"<![CDATA[{videoInfo.Title}]]>")); //root.Add(new XElement("plot", $"<![CDATA[{videoInfo.Title}]]>"));
// 添加视频信息(先清理无效字符) // 添加视频信息(先清理无效字符)
if (!string.IsNullOrWhiteSpace(videoInfo.Title)) if (!string.IsNullOrWhiteSpace(videoInfo.Title))
root.Add(new XElement("title", CleanInvalidXmlChars(videoInfo.Title))); root.Add(new XElement("title", CleanInvalidXmlChars(videoInfo.Title)));
if (!string.IsNullOrWhiteSpace(videoInfo.Author)) //if (!string.IsNullOrWhiteSpace(videoInfo.Author))
root.Add(new XElement("author", CleanInvalidXmlChars(videoInfo.Author))); // root.Add(new XElement("author", CleanInvalidXmlChars(videoInfo.Author)));
// 发布时间(无需清理,因为是格式化的日期字符串)
if (videoInfo.ReleaseDate.HasValue) if (videoInfo.ReleaseDate.HasValue)
{ {
root.Add(new XElement("releasedate", videoInfo.ReleaseDate.Value.ToString("yyyy-MM-dd"))); root.Add(new XElement("releasedate", videoInfo.ReleaseDate.Value.ToString("yyyy-MM-dd")));
@@ -128,7 +131,7 @@ namespace dy.net.utils
if (!string.IsNullOrWhiteSpace(actor.Role)) if (!string.IsNullOrWhiteSpace(actor.Role))
actorElement.Add(new XElement("role", CleanInvalidXmlChars(actor.Role))); actorElement.Add(new XElement("role", CleanInvalidXmlChars(actor.Role)));
actorElement.Add(new XElement("tmdbid", "3141592610000"));//写死一个反正不存在的ID,防止被媒体管理软件误认 actorElement.Add(new XElement("tmdbid", ""));
root.Add(actorElement); root.Add(actorElement);
} }