1、nfo文件优化,修复演员信息(视频作者)

2、图文视频,因版权原因无法下载的音频,系统配置新增默认音频上传入口,上传成功后,后面在遇到无法下载音频时,将用该音频进行图文视频合成的音频数据
3、增加动态视频合成配置。
4、容器重启后,手机端会连续跳转登录页很多次的bug修复
5、其他优化
This commit is contained in:
jianzhichu
2026-01-12 22:13:59 +08:00
parent 858a21ec9e
commit e32d783de4
21 changed files with 1286 additions and 483 deletions
+115
View File
@@ -28,6 +28,18 @@ namespace dy.net.Controllers
private readonly DouyinFollowService douyinFollowService; private readonly DouyinFollowService douyinFollowService;
private readonly DouyinCookieService douyinCookieService; 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) public ConfigController(DouyinCookieService dyCookieService, DouyinCommonService commonService, DouyinQuartzJobService quartzJobService, DouyinFollowService douyinFollowService, DouyinCookieService douyinCookieService)
{ {
this.dyCookieService = dyCookieService; this.dyCookieService = dyCookieService;
@@ -374,5 +386,108 @@ namespace dy.net.Controllers
return ApiResult.Fail("请求失败"); return ApiResult.Fail("请求失败");
} }
} }
/// <summary>
/// 上传音频文件接口
/// </summary>
/// <param name="file">要上传的音频文件</param>
/// <returns>上传结果</returns>
[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<IActionResult> 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("未上传音频");
}
} }
} }
+3 -3
View File
@@ -20,13 +20,13 @@ namespace dy.net.Controllers
this.logInfoService = logInfoService; this.logInfoService = logInfoService;
} }
[HttpGet] [HttpGet("/api/logs/GetLog/{type}/{date}")]
public async Task<IActionResult> GetLog(string type, string date) public async Task<IActionResult> GetLog([FromRoute]string type, [FromRoute] string date)
{ {
var filePath = Path.Combine(webHostEnvironment.IsDevelopment() ? Directory.GetCurrentDirectory() : AppDomain.CurrentDomain.BaseDirectory, "logs", $"log-{type}-{date}.txt"); var filePath = Path.Combine(webHostEnvironment.IsDevelopment() ? Directory.GetCurrentDirectory() : AppDomain.CurrentDomain.BaseDirectory, "logs", $"log-{type}-{date}.txt");
if (!System.IO.File.Exists(filePath)) if (!System.IO.File.Exists(filePath))
{ {
var msg = $"Log file log-{type}-{date}.txt not found."; var msg = $"{date},没有发现{type}的日志";
//Serilog.Log.Error(msg); //Serilog.Log.Error(msg);
return Ok(msg); return Ok(msg);
} }
+41
View File
@@ -4,6 +4,7 @@ using dy.net.service;
using dy.net.utils; using dy.net.utils;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System;
namespace dy.net.Controllers namespace dy.net.Controllers
{ {
@@ -270,5 +271,45 @@ namespace dy.net.Controllers
var data = await douyinVideoService.DeleteInvalidVideo(); var data = await douyinVideoService.DeleteInvalidVideo();
return Ok(data); return Ok(data);
} }
/// <summary>
/// 所有视频重新生成nfo文件
/// </summary>
/// <returns></returns>
[HttpGet("renfo")]
public async Task<IActionResult> 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();
}
} }
} }
@@ -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-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||;</History> <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>
<LastFailureDetails /> <LastFailureDetails />
</PropertyGroup> </PropertyGroup>
</Project> </Project>
-1
View File
@@ -31,7 +31,6 @@ const isMobileBrowser = computed(() => {
const enforceMobileRoute = () => { const enforceMobileRoute = () => {
const targetMobilePath = '/mobile'; const targetMobilePath = '/mobile';
const currentPath = route.path.toLowerCase().trim(); const currentPath = route.path.toLowerCase().trim();
// 核心规则:手机浏览器 → 强制跳转到/mobile(无论当前路由是什么) // 核心规则:手机浏览器 → 强制跳转到/mobile(无论当前路由是什么)
if (isMobileBrowser.value) { if (isMobileBrowser.value) {
if (currentPath !== targetMobilePath) { if (currentPath !== targetMobilePath) {
+1 -1
View File
@@ -368,7 +368,7 @@ const loadLogDetailContent = (log: LogItem) => {
try { try {
const type = log.type.toLowerCase(); const type = log.type.toLowerCase();
const date = log.date; const date = log.date;
const requestParams = `type=${type}&date=${date}`; const requestParams = `${type}/${date}`;
useApiStore() useApiStore()
.apiGetLogs(requestParams) .apiGetLogs(requestParams)
+175 -71
View File
@@ -1,91 +1,195 @@
<template> <template>
<a-form layout="inline" style="margin-top:5px;margin-bottom:5px;"> <div style="margin: 5px 0;">
<a-form-item> <!-- 日志类型按钮组 -->
<a-date-picker v-model:value="dateValue" format="YYYYMMDD" :locale="locale" @change="datePickChange" /> <a-button-group>
</a-form-item> <a-button :type="typeValue === 'debug' ? 'primary' : 'default'" @click="typeValue = 'debug'; loadLogs()" class="type-btn">
<a-form-item> 普通日志
<a-radio-group v-model:value="typeValue" button-style="solid" @change="typeChange"> </a-button>
<a-radio-button value="debug">debug</a-radio-button> <a-button :type="typeValue === 'error' ? 'primary' : 'default'" @click="typeValue = 'error'; loadLogs()" class="type-btn">
<a-radio-button value="error">error</a-radio-button> 错误日志
</a-radio-group> </a-button>
</a-form-item> </a-button-group>
</a-form>
<div class="container"> <!-- 日期操作按钮组核心调整颜色 -->
<a-card title="" :bordered="true"> <a-button-group style="margin-left: 10px;" class="date-btn-group">
<pre>{{ logs }}</pre> <a-button @click="changeDate('prev')">前一天</a-button>
<a-button class="today-btn" @click="changeDate('current')">今天</a-button>
<a-button @click="changeDate('next')">下一天</a-button>
</a-button-group>
</div>
<!-- 日志展示区域 -->
<div class="log-container">
<a-card bordered>
<pre class="log-content">{{ logs }}</pre>
</a-card> </a-card>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { defineComponent, reactive, ref, watch, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useApiStore } from '@/store'; import { useApiStore } from '@/store';
import type { UnwrapRef } from 'vue'; import dayjs from 'dayjs';
import dayjs, { Dayjs } from 'dayjs';
import locale from 'ant-design-vue/es/date-picker/locale/zh_CN';
type RangeValue = [Dayjs, Dayjs];
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
// 初始化dayjs中文环境
dayjs.locale('zh-cn'); dayjs.locale('zh-cn');
const dateValue = ref<Dayjs>(dayjs(Date()));
const typeValue = ref<string>('debug'); // 核心变量(极简:只维护两个核心变量)
const iframeUrl = ref<string>(''); const typeValue = ref<string>('debug'); // 日志类型
const dateValue1 = ref<string>(); const currentDate = ref<dayjs.Dayjs>(dayjs()); // 当前选中的日期(唯一日期变量)
const logs = ref<string>(''); const logs = ref<string>('加载中...'); // 日志内容
dateValue1.value = dayjs(Date()).format('YYYYMMDD');
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`; // 计算可访问的日期范围(近10天:今天 ~ 9天前)
const datePickChange = (e, dateStr) => { const maxDate = computed(() => dayjs()); // 最大日期:今天
dateValue1.value = dateStr; const minDate = computed(() => dayjs().subtract(9, 'day')); // 最小日期:9天前
iframeUrl.value = `type=${typeValue.value}&date=${dateValue1.value}`;
console.log(iframeUrl); // 格式化日期文本(供显示和请求使用)
const currentDateText = computed(() => currentDate.value.format('YYYYMMDD'));
const minDateText = computed(() => minDate.value.format('YYYYMMDD'));
const maxDateText = computed(() => maxDate.value.format('YYYYMMDD'));
// 日期切换核心逻辑(极简:直接修改currentDate
const changeDate = (action: 'prev' | 'current' | 'next') => {
let newDate: dayjs.Dayjs;
switch (action) {
case 'prev':
newDate = currentDate.value.subtract(1, 'day');
break;
case 'next':
newDate = currentDate.value.add(1, 'day');
break;
case 'current':
default:
newDate = dayjs();
break;
}
// 范围校验:限制在10天内
if (newDate.isBefore(minDate.value)) {
newDate = minDate.value;
logs.value = `已到最早可查看日期(${minDateText.value}),无法继续往前`;
} else if (newDate.isAfter(maxDate.value)) {
newDate = maxDate.value;
logs.value = `已到最新可查看日期(${maxDateText.value}),无法继续往后`;
}
// 更新当前日期并加载日志
currentDate.value = newDate;
loadLogs(); loadLogs();
}; };
const typeChange = (e) => { // 加载日志数据
console.log(e.target);
iframeUrl.value = `type=${e.target.value}&date=${dateValue1.value}`;
loadLogs();
};
const mIfrm = ref<any>(null);
// 核心:处理日志时间戳,移除 .xxx +08:00 部分
const formatLogTime = (logContent: string) => {
// 正则匹配:2025-12-11 18:41:39.624 +08:00 格式,捕获前面的日期时间部分
// 正则解释:
// (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) 捕获 年-月-日 时:分:秒
// \.\d+ \+08:00 匹配 .毫秒 +时区 部分
const timeRegex = /(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+ \+08:00/g;
// 替换匹配到的内容,只保留捕获的日期时间部分
return logContent.replace(timeRegex, '$1');
};
onMounted(() => {
// console.log(mIfrm.value);
loadLogs();
});
const loadLogs = () => { const loadLogs = () => {
useApiStore() // 拼接请求参数
.apiGetLogs(iframeUrl.value) const params = `${typeValue.value}/${currentDateText.value}`;
.then((log) => {
// console.log(log);
log = formatLogTime(log);
const lines = log.split('\n'); // 将文本按换行符分割为行数组
const reversedLines = lines.reverse(); // 对行数组进行倒序操作
const reversedText = reversedLines.join('\n'); // 将行数组重新连接为一个字符串
logs.value = reversedText; // 调用接口加载日志
useApiStore()
.apiGetLogs(params)
.then((logContent) => {
if (!logContent) {
logs.value = `${currentDateText.value}】暂无${typeValue.value === 'debug' ? '普通' : '错误'}日志数据`;
return;
}
// 格式化日志时间(移除毫秒和时区)
const formatLog = logContent.replace(/(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.\d+ \+08:00/g, '$1');
// 倒序显示
logs.value = formatLog.split('\n').reverse().join('\n');
})
.catch((err) => {
console.error('加载日志失败:', err);
logs.value = `加载日志失败:${err.message || '网络异常'}`;
}); });
}; };
// 页面挂载时加载初始日志
onMounted(() => {
loadLogs();
});
</script> </script>
<style lang='less' scoped>
html { <style lang='less' scoped>
height: 100vh; // 日志展示容器样式
} .log-container {
.container {
width: 100%; width: 100%;
height: 100%; height: calc(100vh - 80px);
// max-height: 400px; margin-top: 10px;
iframe {
.word-wrap { .log-content {
color: white !important; white-space: pre-wrap;
word-wrap: break-word;
height: 100%;
max-height: 100%;
overflow-y: auto;
margin: 0;
padding: 15px;
background: #f9f9f9;
font-size: 14px;
line-height: 1.5;
}
}
// 按钮组基础样式
:deep(.ant-btn-group) {
.ant-btn {
height: 36px;
padding: 0 18px;
font-size: 14px;
border-radius: 0 !important; // 按钮组圆角处理
transition: all 0.2s ease;
}
// 第一个按钮左圆角
.ant-btn:first-child {
border-radius: 6px 0 0 6px !important;
}
// 最后一个按钮右圆角
.ant-btn:last-child {
border-radius: 0 6px 6px 0 !important;
}
}
// ========== 核心修改:日期按钮颜色 ==========
.date-btn-group {
:deep(.ant-btn) {
// 日期按钮默认样式
background: #fff;
border-color: #e6e6e6;
color: #333;
&:hover {
background: #f0f9ff;
border-color: #00b42a; // hover时边框变绿色
color: #00b42a;
}
}
// “今天”按钮高亮样式(核心颜色修改处)
:deep(.today-btn) {
background: #00b42a !important; // 深绿色背景(可替换成你想要的颜色)
border-color: #00b42a !important;
color: #fff !important;
font-weight: 500;
&:hover {
background: #00c834 !important; // hover时稍浅的绿色
border-color: #00c834 !important;
}
}
}
// ========== 可选:日志类型按钮颜色也同步调整(可选) ==========
.type-btn {
:deep(&.ant-btn-primary) {
background: #722ed1 !important; // 日志类型选中用紫色(可改)
border-color: #722ed1 !important;
color: #fff !important;
&:hover {
background: #8046e0 !important;
border-color: #8046e0 !important;
} }
} }
} }
+367 -108
View File
@@ -1,12 +1,11 @@
<template> <template>
<a-card :bordered="false" :body-style="{ padding: '10px' }"> <a-card :bordered="false" :body-style="{ padding: '10px' }">
<a-form :model="formState" :label-col="labelCol" :rules="rules" :wrapper-col="wrapperCol" ref="formRef" label-align="right"> <a-form :model="formState" :label-col="labelCol" :rules="rules" :wrapper-col="wrapperCol" ref="formRef" label-align="right">
<!-- 原有所有表单内容保持不变 -->
<!-- 任务调度配置 --> <!-- 任务调度配置 -->
<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 }" style="margin-left: 30px">
<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" />
@@ -14,7 +13,7 @@
</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 }" style="margin-left: 30px">
<a-input-number v-model:value="formState.BatchCount" placeholder="请输入每次下载数量上限(最大30)" :min="10" :max="30" /> <a-input-number v-model:value="formState.BatchCount" placeholder="请输入每次下载数量上限(最大30)" :min="10" :max="30" />
<div class="flex items-start mt-1 text-sm text-gray-500"> <div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
@@ -33,13 +32,13 @@
<!-- 文件保存配置 --> <!-- 文件保存配置 -->
<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>
@@ -47,7 +46,7 @@
<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>
@@ -55,8 +54,11 @@
<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>
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" /> <InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>选择文件名占位符顺序为文件名顺序需配合分隔符<br /><strong class="text-gray-700">占位符</strong>{Id}=视频ID{VideoTitle}=标题{ReleaseTime}=发布时间{Author}=博主名{FileHash}=文件哈希{Resolution}=分辨率</span> <span style="color: red">
请选择文件名占位符和模板分隔符文件名命名规则配置仅博主视频有效
</span>
</div> </div>
</a-form-item> </a-form-item>
@@ -86,51 +88,97 @@
<div class="form-section" v-if="downImgVideo"> <div class="form-section" v-if="downImgVideo">
<h3 class="section-title">图文视频</h3> <h3 class="section-title">图文视频</h3>
<a-form-item has-feedback label="是否单独存储" name="ImageViedoSaveAlone" :wrapper-col="{ span: 20 }">
<a-form-item has-feedback label="下载图文视频" name="DownImageVideo" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownImageVideo" @change="downImageVideoHandler" />
<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.DownImageVideo" has-feedback label="单独存储" name="ImageViedoSaveAlone" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.ImageViedoSaveAlone" /> <a-switch v-model:checked="formState.ImageViedoSaveAlone" />
<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>
开启图文视频统一存入抖音授权 Cookie 配置的目录且需提前配置该存储路径关闭按类型分别存入对应文件夹如收藏视频存入收藏视频目录 开启图文视频统一存入抖音授权 Cookie 配置的目录且需提前配置该存储路径关闭按类型分别存入对应文件夹如收藏视频存入收藏视频目录
</span> </span>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item has-feedback label="下载图文视频" name="DownImageVideo" :wrapper-col="{ span: 20 }"> <a-form-item v-if="formState.DownImageVideo" has-feedback label="保留音频" name="DownMp3" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownImageVideo" />
<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 has-feedback label="下载音频文件" name="DownMp3" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownMp3" /> <a-switch v-model:checked="formState.DownMp3" />
<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="DownImage" :wrapper-col="{ span: 20 }"> <a-form-item v-if="formState.DownImageVideo" has-feedback label="保留图片" name="DownImage" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownImage" /> <a-switch v-model:checked="formState.DownImage" />
<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>
</a-form-item>
<a-form-item has-feedback label="下载动态视频" name="DownImage" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownDynamicVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>针对有些视频是多个视频生成的实际是分为多个视频启用后将会分别下载多个视频名字带_001,002这样</span>
</div> </div>
</a-form-item> </a-form-item>
<a-form-item v-if="formState.DownImageVideo" has-feedback label="默认音频" name="AudioFile" :wrapper-col="{ span: 20 }">
<!-- 音频上传与播放器容器 -->
<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-button type="default">
<UploadOutlined /> 选择默认音频文件
</a-button>
</a-upload>
<!-- 新增启用原生完整控件controls属性自带可拖拽进度条 -->
<div class="audio-player" v-if="audioUrl" style="flex: 1; max-width: 500px;">
<audio ref="audioInstance" :src="audioUrl" controls controlsList="nodownload" @ended="() => isPlaying = false" @pause="() => isPlaying = false" @play="() => isPlaying = true" class="native-audio-player">
您的浏览器不支持HTML5音频播放请升级至现代浏览器
</audio>
</div>
</div>
<div class="flex items-start mt-1 text-sm text-gray-500" style="color:red">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>当下载图文视频时音频因版权原因无法下载时将用该音频文件作为合成视频的音频</span>
</div>
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>支持格式MP3WAVAACFLACOGGM4AWMA单个文件最大20MB</span>
</div>
</a-form-item>
</div>
<div class="form-section">
<h3 class="section-title">动态视频</h3>
<a-form-item has-feedback label="下载动态视频" name="DownDynamicVideo" :wrapper-col="{ span: 20 }">
<a-switch v-model:checked="formState.DownDynamicVideo" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>针对有些视频是多个视频生成的实际是分为多个视频开启后将会分别下载多个视频名字带_001,002这样</span>
</div>
</a-form-item>
<a-form-item v-if="formState.DownDynamicVideo" has-feedback label="合并动态视频" name="MegDynamicVideo" :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" />
<div class="flex items-start mt-1 text-sm text-gray-500">
<InfoCircleOutlined class="text-blue-400 mr-1 mt-0.5" />
<span>开启后将保留合成视频之前的每个短视频</span>
</div>
</a-form-item>
</div> </div>
<!-- 系统配置 --> <!-- 系统配置 -->
<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 拖拽容器 -->
@@ -150,7 +198,6 @@
鼠标放到点击鼠标左键即可拖拽调整优先级</span> 鼠标放到点击鼠标左键即可拖拽调整优先级</span>
</div> </div>
</a-form-item> </a-form-item>
</div> </div>
<!-- 操作按钮 --> <!-- 操作按钮 -->
@@ -168,13 +215,11 @@
</a-form> </a-form>
</a-card> </a-card>
<!-- 配置导入导出悬浮按钮优化布局+动画 -->
<div class="config-float-btn-container"> <div class="config-float-btn-container">
<!-- 主按钮 --> <!-- 主按钮 -->
<a-tooltip title="配置导出导入" placement="left"> <a-tooltip title="配置导出导入" placement="left">
<a-button class="main-float-btn" type="primary" shape="circle"> <a-button class="main-float-btn" type="primary" shape="circle">
<tool-outlined /> <shake-outlined />
</a-button> </a-button>
</a-tooltip> </a-tooltip>
@@ -183,38 +228,47 @@
<!-- 关键修改添加 Tooltip 组件包裹导出按钮 --> <!-- 关键修改添加 Tooltip 组件包裹导出按钮 -->
<a-tooltip title="导出配置" placement="left"> <a-tooltip title="导出配置" placement="left">
<a-button class="sub-float-btn export-btn" type="default" shape="circle" @click="exportConfig"> <a-button class="sub-float-btn export-btn" type="default" shape="circle" @click="exportConfig">
<cloud-download-outlined /> <CloudDownloadOutlined />
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<!-- 关键修改添加 Tooltip 组件包裹导入按钮 --> <!-- 关键修改添加 Tooltip 组件包裹导入按钮 -->
<a-tooltip title="导入配置" placement="left"> <a-tooltip title="导入配置" placement="left">
<a-button class="sub-float-btn import-btn" type="default" shape="circle" @click="triggerImportFile"> <a-button class="sub-float-btn import-btn" type="default" shape="circle" @click="triggerImportFile">
<cloud-upload-outlined /> <CloudUploadOutlined />
</a-button> </a-button>
</a-tooltip> </a-tooltip>
</div> </div>
<input ref="importFileInput" type="file" accept=".json" class="import-file-input" @change="handleImportFile"> <input ref="importFileInput" type="file" accept=".json" class="import-file-input" @change="handleImportFile">
</div> </div>
<div class="top-right-float-btn-container">
<a-tooltip title="重新对同步好的视频文件进行刮削" placement="bottom">
<a-button class="top-right-float-btn" type="primary" shape="circle" @click="renfo">
<bulb-outlined />
</a-button>
</a-tooltip>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { reactive, toRaw, ref, watch, onMounted, computed, nextTick } from 'vue'; import { reactive, toRaw, ref, watch, onMounted, computed, nextTick } from 'vue';
import type { UnwrapRef } from 'vue'; import type { UnwrapRef } from 'vue';
import { Form, Tooltip } from 'ant-design-vue'; // 关键修改:导入 Tooltip 组件 import { Form, Tooltip } from 'ant-design-vue';
import type { Rule } from 'ant-design-vue/es/form'; import type { Rule } from 'ant-design-vue/es/form';
import type { FormInstance } from 'ant-design-vue'; import type { FormInstance } from 'ant-design-vue';
import { useApiStore } from '@/store'; import { useApiStore } from '@/store';
import { message } from 'ant-design-vue'; import { message, Modal } from 'ant-design-vue';
import { Sortable } from 'sortablejs'; import { Sortable } from 'sortablejs';
import type { UploadProps } from 'ant-design-vue/es/upload/interface';
import { import {
InfoCircleOutlined, InfoCircleOutlined,
SaveOutlined, SaveOutlined,
CheckOutlined, CheckOutlined,
ToolOutlined, // 修正:原代码中用了 tool-outlined 但未导入 ToolOutlined,
CloudDownloadOutlined, CloudDownloadOutlined,
CloudUploadOutlined, CloudUploadOutlined,
UploadOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
// 表单引用 // 表单引用
@@ -238,7 +292,15 @@ const downImgVideo = ref(true);
const floatMenuVisible = ref(false); const floatMenuVisible = ref(false);
const importFileInput = ref<HTMLInputElement | null>(null); const importFileInput = ref<HTMLInputElement | null>(null);
// 表单数据结构(新增 FullFollowedTitleTemplate 字段 // 新增:文件上传相关状态(已删除 uploadFileList,仅保留 isUploading 可选
const isUploading = ref(false);
//开启或关闭合成视频
const downImageVideoHandler = () => {
if (formState.DownImageVideo) {
}
};
// 表单数据结构(包含 FullFollowedTitleTemplate 字段)
interface FormState { interface FormState {
Cron: number; Cron: number;
Id: string; Id: string;
@@ -250,12 +312,14 @@ interface FormState {
DownImage: boolean; DownImage: boolean;
DownMp3: boolean; DownMp3: boolean;
ImageViedoSaveAlone: boolean; ImageViedoSaveAlone: boolean;
FollowedTitleTemplate: string[]; // 占位符数组 FollowedTitleTemplate: string[];
FollowedTitleSeparator: string; // 分隔符 FollowedTitleSeparator: string;
FullFollowedTitleTemplate: string; // 新增:完整模板字符串(自动生成) FullFollowedTitleTemplate: string;
AutoDistinct: boolean; AutoDistinct: boolean;
PriorityLevel: string; PriorityLevel: string;
DownDynamicVideo: boolean; DownDynamicVideo: boolean;
MegDynamicVideo: boolean; // 补充原有缺失字段
KeepDynamicVideo: boolean; // 补充原有缺失字段
OnlySyncNew: boolean; OnlySyncNew: boolean;
} }
@@ -273,24 +337,25 @@ const formState: UnwrapRef<FormState> = reactive({
ImageViedoSaveAlone: true, ImageViedoSaveAlone: true,
FollowedTitleTemplate: [], FollowedTitleTemplate: [],
FollowedTitleSeparator: '', FollowedTitleSeparator: '',
FullFollowedTitleTemplate: '', // 初始为空 FullFollowedTitleTemplate: '',
AutoDistinct: false, AutoDistinct: false,
PriorityLevel: '', PriorityLevel: '',
DownDynamicVideo: false, DownDynamicVideo: false,
MegDynamicVideo: false, // 初始化缺失字段
KeepDynamicVideo: false, // 初始化缺失字段
OnlySyncNew: false, OnlySyncNew: false,
}); });
// 实时计算完整模板(可选:让用户实时预览,提交时无需重复计算) // 实时计算完整模板
const computeFullTemplate = computed(() => { const computeFullTemplate = computed(() => {
return formState.FollowedTitleTemplate.join(formState.FollowedTitleSeparator); return formState.FollowedTitleTemplate.join(formState.FollowedTitleSeparator);
}); });
// 监听占位符/分隔符变化,实时更新预览(可选) // 监听占位符/分隔符变化,实时更新预览
watch( watch(
[() => [...formState.FollowedTitleTemplate], () => formState.FollowedTitleSeparator], [() => [...formState.FollowedTitleTemplate], () => formState.FollowedTitleSeparator],
() => { () => {
if (!formState.UperUseViedoTitle) { if (!formState.UperUseViedoTitle) {
// 只有关闭标题当文件名时才更新预览
formState.FullFollowedTitleTemplate = computeFullTemplate.value; formState.FullFollowedTitleTemplate = computeFullTemplate.value;
} }
}, },
@@ -302,21 +367,18 @@ watch(
() => formState.UperUseViedoTitle, () => formState.UperUseViedoTitle,
(isEnabled) => { (isEnabled) => {
if (isEnabled) { if (isEnabled) {
// 开启时清空模板相关数据,避免数据残留
formState.FollowedTitleTemplate = []; formState.FollowedTitleTemplate = [];
formState.FollowedTitleSeparator = ''; formState.FollowedTitleSeparator = '';
formState.FullFollowedTitleTemplate = ''; formState.FullFollowedTitleTemplate = '';
// 清空相关表单项的校验状态
formRef.value?.clearValidate(['FollowedTitleTemplate', 'FollowedTitleSeparator', 'FullFollowedTitleTemplate']); formRef.value?.clearValidate(['FollowedTitleTemplate', 'FollowedTitleSeparator', 'FullFollowedTitleTemplate']);
} else { } else {
// 关闭时重新计算完整模板
formState.FullFollowedTitleTemplate = computeFullTemplate.value; formState.FullFollowedTitleTemplate = computeFullTemplate.value;
} }
}, },
{ immediate: true } { immediate: true }
); );
// 表单校验规则(新增 FullFollowedTitleTemplate 校验) // 表单校验规则
const rules: Record<string, Rule[]> = { const rules: Record<string, Rule[]> = {
FollowedTitleSeparator: [{ max: 5, message: '分隔符长度不能超过5个字符', trigger: 'change' }], FollowedTitleSeparator: [{ max: 5, message: '分隔符长度不能超过5个字符', trigger: 'change' }],
FullFollowedTitleTemplate: [{ max: 200, message: '完整模板字符串长度不能超过200个字符', trigger: 'change' }], FullFollowedTitleTemplate: [{ max: 200, message: '完整模板字符串长度不能超过200个字符', trigger: 'change' }],
@@ -326,17 +388,15 @@ const rules: Record<string, Rule[]> = {
const labelCol = { style: { width: '150px', textAlign: 'right' } }; const labelCol = { style: { width: '150px', textAlign: 'right' } };
const wrapperCol = { span: 12 }; const wrapperCol = { span: 12 };
// 获取配置数据(适配 FullFollowedTitleTemplate 字段) // 获取配置数据
const getConfig = () => { const getConfig = () => {
useApiStore() useApiStore()
.apiGetConfig() .apiGetConfig()
.then((res) => { .then((res) => {
if (res.code === 0) { if (res.code === 0) {
// 优先从 FullFollowedTitleTemplate 解析占位符数组(确保数据一致)
const fullTemplate = res.data.FullFollowedTitleTemplate || res.data.followedTitleTemplate || ''; const fullTemplate = res.data.FullFollowedTitleTemplate || res.data.followedTitleTemplate || '';
const parsedTemplateArr = parseTemplateToArr(fullTemplate); const parsedTemplateArr = parseTemplateToArr(fullTemplate);
// 赋值所有字段(包含新增的 FullFollowedTitleTemplate
Object.assign(formState, { Object.assign(formState, {
Cron: res.data.cron, Cron: res.data.cron,
Id: res.data.id, Id: res.data.id,
@@ -349,15 +409,17 @@ const getConfig = () => {
DownMp3: res.data.downMp3, DownMp3: res.data.downMp3,
FollowedTitleTemplate: parsedTemplateArr, FollowedTitleTemplate: parsedTemplateArr,
FollowedTitleSeparator: res.data.followedTitleSeparator || '', FollowedTitleSeparator: res.data.followedTitleSeparator || '',
FullFollowedTitleTemplate: fullTemplate, // 回显完整模板 FullFollowedTitleTemplate: fullTemplate,
ImageViedoSaveAlone: res.data.imageViedoSaveAlone, ImageViedoSaveAlone: res.data.imageViedoSaveAlone,
AutoDistinct: res.data.autoDistinct, AutoDistinct: res.data.autoDistinct,
PriorityLevel: res.data.priorityLevel, PriorityLevel: res.data.priorityLevel,
DownDynamicVideo: res.data.downDynamicVideo, DownDynamicVideo: res.data.downDynamicVideo,
MegDynamicVideo: res.data.megDynamicVideo || false, // 补充赋值
KeepDynamicVideo: res.data.keepDynamicVideo || false, // 补充赋值
OnlySyncNew: res.data.onlySyncNew, OnlySyncNew: res.data.onlySyncNew,
}); });
tagData.value = JSON.parse(res.data.priorityLevel); tagData.value = JSON.parse(res.data.priorityLevel || '[]');
} else { } else {
message.error(res.message || '获取配置失败', 8); message.error(res.message || '获取配置失败', 8);
} }
@@ -368,7 +430,7 @@ const getConfig = () => {
}); });
}; };
// 模板字符串 → 占位符数组(原有逻辑不变) // 模板字符串 → 占位符数组
const parseTemplateToArr = (templateStr: string | null | undefined) => { const parseTemplateToArr = (templateStr: string | null | undefined) => {
if (!templateStr || typeof templateStr !== 'string' || templateStr.trim() === '') { if (!templateStr || typeof templateStr !== 'string' || templateStr.trim() === '') {
return []; return [];
@@ -382,24 +444,153 @@ const parseTemplateToArr = (templateStr: string | null | undefined) => {
// 拖拽容器 ref // 拖拽容器 ref
const tagContainer = ref(null); const tagContainer = ref(null);
let sortableInstance = ref(null); let sortableInstance = ref<any>(null);
// 模拟 Tag 数据(替换为你的实际数据) // 模拟 Tag 数据
const tagData = ref([ const tagData = ref([
{ id: 1, name: '喜欢的视频', sort: 1 }, { id: 1, name: '喜欢的视频', sort: 1 },
{ id: 2, name: '收藏的视频', sort: 2 }, { id: 2, name: '收藏的视频', sort: 2 },
{ id: 3, name: '关注的视频', sort: 3 }, { id: 3, name: '关注的视频', sort: 3 },
]); ]);
// 重新计算所有 Tag 的 sort 值(核心方法) // 重新计算所有 Tag 的 sort 值
const updateTagSort = () => { const updateTagSort = () => {
tagData.value.forEach((item, index) => { tagData.value.forEach((item, index) => {
item.sort = index + 1; // sort = 索引+1(保证顺序和 sort 一一对应) item.sort = index + 1;
}); });
}; };
// ========== 文件上传相关方法(已修改:移除进度条逻辑) ==========
/**
* 上传前校验
*/
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// 1. 校验文件大小(10MB
const isLt20M = file.size / 1024 / 1024 < 20;
if (!isLt20M) {
message.error('文件大小不能超过20MB!');
return false;
}
// 2. 校验文件类型
const acceptTypes = ['.mp3', '.wav', '.aac', '.flac', '.ogg', '.m4a', '.wma'];
const fileExt = '.' + file.name.split('.').pop()?.toLowerCase();
if (!acceptTypes.includes(fileExt || '')) {
message.error('仅支持MP3、WAV、AAC、FLAC、OGG、M4A、WMA格式的音频文件!');
return false;
}
return true;
};
// 1. 新增音频播放相关状态(放在现有状态定义区域,如 isUploading 下方)
const audioUrl = ref('/api/config/defaudio'); // 上传成功后的音频文件URL
const isPlaying = ref(false); // 音频是否正在播放
const audioInstance = ref<HTMLAudioElement | null>(null); // 音频播放器实例
// 2. 改造原有 customUpload 方法,保存上传成功后的音频URL
const customUpload: UploadProps['customRequest'] = (options) => {
const { file, onSuccess, onError } = options;
isUploading.value = true;
// 构造FormData
const formData = new FormData();
formData.append('file', file);
useApiStore()
.apiUploadAudio(formData)
.then((res) => {
console.log(res);
if (res.code === 0) {
// message.success('音频文件上传成功!');
audioUrl.value = `/api/config/defaudio?t=${Date.now()}`;
onSuccess(res);
} else {
message.error(res.message || '文件上传失败!');
onError(new Error(res.message || '上传失败'), file);
}
})
.catch((error) => {
console.error('文件上传失败:', error);
message.error('文件上传失败,请稍后重试!');
onError(error, file);
})
.finally(() => {
isUploading.value = false;
});
};
// 新增:封装 load() 为 Promise (可复用)
const audioLoadPromise = (audio: HTMLAudioElement): Promise<void> => {
return new Promise((resolve, reject) => {
// 加载成功回调
const onLoadSuccess = () => {
audio.removeEventListener('canplaythrough', onLoadSuccess);
audio.removeEventListener('error', onLoadError);
resolve();
};
// 加载失败回调
const onLoadError = () => {
audio.removeEventListener('canplaythrough', onLoadSuccess);
audio.removeEventListener('error', onLoadError);
reject(new Error('音频加载失败'));
};
audio.addEventListener('canplaythrough', onLoadSuccess);
audio.addEventListener('error', onLoadError);
audio.load();
});
};
// 修改 refreshAudioPlayer 为 async 方法
const refreshAudioPlayer = async () => {
if (!audioInstance.value) return;
try {
// 1. 暂停播放、重置状态
audioInstance.value.pause();
audioInstance.value.currentTime = 0;
isPlaying.value = false;
// 2. 等待加载完成(核心:解决时序冲突)
await audioLoadPromise(audioInstance.value);
// 3. 加载完成后,安全调用 play()
await audioInstance.value.play();
isPlaying.value = true;
// message.success('音频已自动播放');
} catch (err) {
if ((err as Error).message !== '音频加载失败') {
// 区分加载失败和自动播放失败
message.warning('自动播放失败,请手动点击播放按钮(浏览器限制)');
} else {
message.error('音频加载失败,无法自动播放');
}
console.log('错误详情:', err);
isPlaying.value = false;
}
};
// 监听 audioUrl 变化,重置播放状态(可选,优化用户体验)
watch(
audioUrl,
(newVal, oldVal) => {
// 排除初始值(仅当 url 发生有效变更时刷新)
if (newVal && newVal !== oldVal) {
isPlaying.value = false;
// 核心:调用刷新方法,强制加载新音频
refreshAudioPlayer();
} else if (!newVal) {
// 若 url 为空,仅重置状态
isPlaying.value = false;
if (audioInstance.value) {
audioInstance.value.currentTime = 0;
}
}
},
{ immediate: false }
); // 关闭 immediate,避免初始加载时触发
// 组件挂载时获取配置 // 组件挂载时获取配置
onMounted(async () => { onMounted(async () => {
getConfig(); getConfig();
// 等待 Form 组件完全渲染(关键:解决 DOM 未挂载问题)
await nextTick(); await nextTick();
if (tagContainer.value) { if (tagContainer.value) {
sortableInstance.value = new Sortable(tagContainer.value, { sortableInstance.value = new Sortable(tagContainer.value, {
@@ -408,14 +599,9 @@ onMounted(async () => {
ghostClass: 'tag-ghost', ghostClass: 'tag-ghost',
preventOnFilter: true, preventOnFilter: true,
onEnd: (evt) => { onEnd: (evt) => {
// 1. 调整 Tag 顺序
const [movedTag] = tagData.value.splice(evt.oldIndex, 1); const [movedTag] = tagData.value.splice(evt.oldIndex, 1);
tagData.value.splice(evt.newIndex, 0, movedTag); tagData.value.splice(evt.newIndex, 0, movedTag);
// 2. 重新计算所有 Tag 的 sort 值(关键:同步 sort 和顺序)
updateTagSort(); updateTagSort();
// 打印结果(验证 sort 是否同步)
console.log('最新 Tag 数据(含 sort):', tagData.value); console.log('最新 Tag 数据(含 sort):', tagData.value);
}, },
}); });
@@ -424,28 +610,23 @@ onMounted(async () => {
} }
}); });
// 提交表单(核心:自动生成完整模板字符串) // 提交表单
const onSubmit = () => { const onSubmit = () => {
formRef.value formRef.value
.validate() ?.validate()
.then(() => { .then(() => {
// 1. 如果开启了标题当文件名,清空模板相关字段
let fullTemplate = ''; let fullTemplate = '';
let templateTitle = '';
if (!formState.UperUseViedoTitle) { if (!formState.UperUseViedoTitle) {
// 自动拼接完整模板字符串(数组 + 分隔符)
fullTemplate = formState.FollowedTitleTemplate.join(formState.FollowedTitleSeparator); fullTemplate = formState.FollowedTitleTemplate.join(formState.FollowedTitleSeparator);
} }
// 2. 构造提交数据(包含三个模板相关字段)
const submitData = { const submitData = {
...toRaw(formState), ...toRaw(formState),
FullFollowedTitleTemplate: fullTemplate, // 确保提交最新拼接结果 FullFollowedTitleTemplate: fullTemplate,
FollowedTitleTemplate: formState.FollowedTitleTemplate.join(''), FollowedTitleTemplate: formState.FollowedTitleTemplate.join(''),
PriorityLevel: JSON.stringify(tagData.value), PriorityLevel: JSON.stringify(tagData.value),
}; };
// 3. 提交接口
useApiStore() useApiStore()
.apiUpdateConfig(submitData) .apiUpdateConfig(submitData)
.then((res) => { .then((res) => {
@@ -476,27 +657,24 @@ const onUpdate = () => {
const onCancel = () => { const onCancel = () => {
componentDisabled.value = true; componentDisabled.value = true;
formRef.value?.clearValidate(); formRef.value?.clearValidate();
// 取消时恢复完整模板预览(只有关闭标题当文件名时)
if (!formState.UperUseViedoTitle) { if (!formState.UperUseViedoTitle) {
formState.FullFollowedTitleTemplate = computeFullTemplate.value; formState.FullFollowedTitleTemplate = computeFullTemplate.value;
} }
}; };
const importOrexportConf = ref(); const importOrexportConf = ref<any>(null);
// 新增:导出配置 // 导出配置
const exportConfig = () => { const exportConfig = () => {
try { try {
useApiStore() useApiStore()
.ExportConf() .ExportConf()
.then((res) => { .then((res) => {
if (res.code == 0) { if (res.code === 0) {
importOrexportConf.value = res.data; importOrexportConf.value = res.data;
// 转换为JSON字符串并格式化
const jsonStr = JSON.stringify(importOrexportConf.value, null, 2); const jsonStr = JSON.stringify(importOrexportConf.value, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' }); const blob = new Blob([jsonStr], { type: 'application/json' });
// 创建下载链接
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -504,7 +682,6 @@ const exportConfig = () => {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
// 清理
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
@@ -513,22 +690,22 @@ const exportConfig = () => {
} }
}) })
.catch((x) => { .catch((x) => {
console.log(x); console.error('导出接口请求失败:', x);
}); });
} catch (error) { } catch (error) {
console.error('导出配置及手动关注列表失败:', error); console.error('导出配置及手动关注列表失败:', error);
message.error('导出配置及手动关注列表,请稍后重试'); message.error('导出配置及手动关注列表失败,请稍后重试');
} }
}; };
// 新增:触发导入文件选择 // 触发导入文件选择
const triggerImportFile = () => { const triggerImportFile = () => {
if (importFileInput.value) { if (importFileInput.value) {
importFileInput.value.click(); importFileInput.value.click();
} }
}; };
// 新增:处理导入文件 // 处理导入文件
const handleImportFile = (e: Event) => { const handleImportFile = (e: Event) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
const file = target.files?.[0]; const file = target.files?.[0];
@@ -537,14 +714,12 @@ const handleImportFile = (e: Event) => {
return; return;
} }
// 验证文件类型
if (file.type !== 'application/json' && !file.name.endsWith('.json')) { if (file.type !== 'application/json' && !file.name.endsWith('.json')) {
message.error('请选择JSON格式的配置文件'); message.error('请选择JSON格式的配置文件');
target.value = ''; target.value = '';
return; return;
} }
// 读取文件内容
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
try { try {
@@ -553,25 +728,49 @@ const handleImportFile = (e: Event) => {
useApiStore() useApiStore()
.ImportConf(importData) .ImportConf(importData)
.then((res) => { .then((res) => {
if (res.code == 0) { if (res.code === 0) {
message.success('配置及手动关注列表导入成功,下次运行会按新的配置规则运行。'); message.success('配置及手动关注列表导入成功,下次运行会按新的配置规则运行。');
floatMenuVisible.value = false; floatMenuVisible.value = false;
getConfig(); getConfig();
} else { } else {
message.error(`配置及手动关注列表导入失败`); message.error('配置及手动关注列表导入失败');
} }
}); });
} catch (error) { } catch (error) {
console.error('解析配置文件失败:', error); console.error('解析配置文件失败:', error);
message.error(`配置及手动关注列表导入失败:${(error as Error).message}`); message.error(`配置及手动关注列表导入失败:${(error as Error).message}`);
} finally { } finally {
// 清空文件选择
target.value = ''; target.value = '';
} }
}; };
reader.readAsText(file); reader.readAsText(file);
}; };
const renfo = () => {
// AntD 确认弹窗:Modal.confirm
Modal.confirm({
title: '操作确认', // 弹窗标题
content:
'确认要重新生成所有视频的.nfo刮削文件吗?根据视频数量,该操作可能需要较长时间,操作后可查看日志信息,然后进emby查看最新刮削信息。', // 弹窗提示内容
okText: '确认执行', // 确认按钮文本
cancelText: '取消', // 取消按钮文本
iconType: 'warning', // 警告图标(强化提醒效果,防误操作)
// 用户点击「确认」时触发
onOk() {
// 执行原有接口请求逻辑
useApiStore()
.Renfo()
.then((r) => {
if (r.code == 0) {
message.success(
'所有视频将重新生成.nfo刮削文件。根据视频数量,可能需要的时间不同,稍后可以到emby查看最新的刮削信息'
);
}
});
},
onCancel() {},
});
};
</script> </script>
<style lang='less' scoped> <style lang='less' scoped>
@@ -632,18 +831,18 @@ const handleImportFile = (e: Event) => {
color: #444 !important; color: #444 !important;
font-weight: 500; font-weight: 500;
} }
/* 关键修改:确保拖拽容器不限制子元素 */
/* 拖拽容器样式 */
.tag-drag-container { .tag-drag-container {
position: relative; /* 必须:让拖拽的 ghost 元素不被截断 */ position: relative;
overflow: visible !important; /* 覆盖 Form 可能的 overflow: hidden */ overflow: visible !important;
} }
.tag-
/* Tag 列表布局(换行显示) */ /* Tag 列表布局(换行显示) */
.tag-list { .tag-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; /* Tag 之间间距 */ gap: 12px;
} }
/* 拖拽中的 Tag 占位样式 */ /* 拖拽中的 Tag 占位样式 */
@@ -657,7 +856,7 @@ const handleImportFile = (e: Event) => {
.drag-handle { .drag-handle {
font-size: 14px; font-size: 14px;
color: #666; color: #666;
user-select: none; /* 禁止选中文字 */ user-select: none;
} }
.drag-handle:hover { .drag-handle:hover {
@@ -674,33 +873,33 @@ const handleImportFile = (e: Event) => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
// 1. 悬浮按钮容器(恢复固定定位,作为子按钮的定位基准) // 悬浮按钮容器
.config-float-btn-container { .config-float-btn-container {
position: fixed; // 关键:恢复固定定位,确保在页面右下角 position: fixed;
right: 30px; right: 30px;
bottom: 100px; bottom: 100px;
z-index: 1000; z-index: 1000;
width: 60px; // 与主按钮宽度一致,确保居中 width: 60px;
height: auto; // 自适应高度,不限制子按钮展开 height: auto;
&:hover { &:hover {
cursor: default; cursor: default;
} }
} }
// 2. 子按钮容器(修正定位,基于父容器居中) // 子按钮容器
.float-sub-btn-wrapper { .float-sub-btn-wrapper {
position: absolute; position: absolute;
bottom: 70px; // 主按钮高度(60px)+ 间距(10px),向上展开 bottom: 70px;
left: 50%; left: 50%;
transform: translateX(-50%); // 水平居中 transform: translateX(-50%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
// 其他样式(主按钮、子按钮)保持不变 // 主按钮样式
.main-float-btn { .main-float-btn {
width: 60px; width: 60px;
height: 60px; height: 60px;
@@ -709,17 +908,18 @@ const handleImportFile = (e: Event) => {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover { &:hover {
transform: scale(1.05); // 移除多余的 translateX(-50%) transform: scale(1.05);
} }
} }
// 子按钮样式
.sub-float-btn { .sub-float-btn {
width: 50px; width: 50px;
height: 50px; height: 50px;
font-size: 16px; font-size: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
opacity: 0; opacity: 0;
transform: translateY(10px) scale(0.9); // 仅保留垂直偏移,水平居中由父容器控制 transform: translateY(10px) scale(0.9);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
&.export-btn { &.export-btn {
@@ -733,15 +933,74 @@ const handleImportFile = (e: Event) => {
.config-float-btn-container:hover .sub-float-btn { .config-float-btn-container:hover .sub-float-btn {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); // 恢复正常位置 transform: translateY(0) scale(1);
} }
// 隐藏文件导入输入框
.import-file-input { .import-file-input {
display: none; display: none;
} }
// 修复子按钮tooltip显示 // 修复子按钮tooltip层级
:deep(.ant-tooltip) { :deep(.ant-tooltip) {
z-index: 1001 !important; z-index: 1001 !important;
} }
// 上传组件样式适配
:deep(.ant-upload) {
display: inline-block;
}
:deep(.ant-upload.ant-upload-select) {
display: inline-block;
}
// 音频上传与播放器容器样式
.audio-upload-player-wrapper {
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
}
}
// 音频播放器样式优化
:deep(.audio-player audio) {
// border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 2px;
&:hover {
border-color: #1890ff;
}
}
// 播放/清空按钮 hover 效果
:deep(.audio-player .ant-btn-text:hover) {
background-color: #f5f5f5;
}
// 右上角悬浮按钮容器
.top-right-float-btn-container {
position: fixed;
top: 100px; // 距离顶部间距
right: 30px; // 距离右侧间距(与现有底部按钮对齐)
z-index: 1000; // 保证悬浮在最上层
}
// 右上角悬浮按钮样式
.top-right-float-btn {
width: 56px;
height: 56px;
font-size: 18px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
opacity: 0.6; // 默认半透明(0-1之间,0.6为适中的半透明效果)
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); // 平滑过渡动画
// 鼠标悬浮时:移除半透明,轻微放大增强交互感
&:hover {
opacity: 1; // 移除半透明,完全不透明
transform: scale(1.05); // 轻微放大,提升交互体验(可选,可删除)
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); // 悬浮时阴影加深(可选,可删除)
}
}
</style> </style>
+77 -21
View File
@@ -71,10 +71,13 @@
<a-list size="small" bordered :data-source="deleteVideos"> <a-list size="small" bordered :data-source="deleteVideos">
<template #renderItem="{item, index}"> <template #renderItem="{item, index}">
<a-list-item> <a-list-item>
<!-- 新增文本容器用于控制省略号 -->
<span> <div class="delete-video-title-container">
{{index+1}}. {{ item.videoTitle }} <span class="delete-video-index">{{ index + 1 }}.</span>
</span> <span class="delete-video-title" :title="item.videoTitle || '无标题'">
{{ item.videoTitle }}
</span>
</div>
<a-button type="text" size="small" class="copy-delete-video-btn" @click="(e) => copyVideoPath(item.videoSavePath)"> <a-button type="text" size="small" class="copy-delete-video-btn" @click="(e) => copyVideoPath(item.videoSavePath)">
<CopyOutlined /> 复制 <CopyOutlined /> 复制
@@ -1313,34 +1316,87 @@ onMounted(() => {
text-decoration-color: #1890ff; text-decoration-color: #1890ff;
text-decoration-thickness: 1px; text-decoration-thickness: 1px;
} }
/* 已删除视频抽屉 - 列表容器基础样式 */
/* 已删除视频条目样式 */ :deep(.ant-drawer-body) {
.delete-video-text { padding: 16px !important;
flex: 1; /* 文本占满剩余空间 */ overflow-y: auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 8px;
} }
/* 复制按钮样式 */ :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 { .copy-delete-video-btn {
padding: 0 4px !important; padding: 0 8px !important;
height: 24px !important; height: 28px !important;
font-size: 12px !important; font-size: 12px !important;
color: #1890ff !important; color: #1890ff !important;
flex: 0 0 auto; /* 按钮不收缩、不放大,固定宽度 */
} }
.copy-delete-video-btn:hover { .copy-delete-video-btn:hover {
color: #40a9ff !important; color: #40a9ff !important;
background-color: #f0f9ff !important; background-color: #f0f9ff !important;
border-radius: 4px !important;
} }
/* 列表项布局调整 */ /* 可选:适配移动端,优化小屏幕显示 */
:deep(.ant-list-item) { @media (max-width: 768px) {
display: flex !important; .delete-video-title-container {
align-items: center !important; margin-right: 12px;
justify-content: space-between !important; }
padding: 8px 16px !important;
.delete-video-title {
font-size: 13px;
}
.copy-delete-video-btn {
padding: 0 6px !important;
height: 24px !important;
}
} }
</style> </style>
+102 -26
View File
@@ -13,21 +13,69 @@ interface NaviGuard {
after?: NavigationHookAfter; after?: NavigationHookAfter;
} }
const loginGuard: NavigationGuard = function (to, from) { // ========== 新增:移动端检测核心函数 ==========
// console.log('Authorization', http.checkAuthorization()) /**
const account = useAccountStore(); * 检测是否为移动端设备(UA + 屏幕宽度双检测)
if (!http.checkAuthorization() && !/^\/(init|login|home|mobile)?$/.test(to.fullPath)) { */
console.log(123) const isMobile = (): boolean => {
console.log(to.fullPath) const userAgent = navigator.userAgent.toLowerCase();
account.setLogged(false) const mobileUaReg = /iphone|android|ipad|ipod|mobile|wap|symbian|windows ce|blackberry|webos|ucbrowser/i;
return '/login'; const isSmallScreen = window.innerWidth < 768;
} else { 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: '/', path: '/',
name: 'login', name: 'login',
redirect: '/login', redirect: '/login',
@@ -40,22 +88,23 @@ const dynamicinitRoute =
component: () => import('@/pages/login'), component: () => import('@/pages/login'),
}; };
const InitGuard: NavigationGuard = function (to, from, next) {
const InitGuard: NavigationGuard = function (to, from) { // 补充next参数
if (to.fullPath != '/login') { if (to.fullPath != '/login') {
if (!router.hasRoute('login')) { if (!router.hasRoute('login')) {
router.addRoute(dynamicinitRoute) router.addRoute(dynamicinitRoute);
} }
router.push('/login') next('/login');
} else {
next();
} }
}; };
// 进度条 // 进度条
const ProgressGuard: NaviGuard = { const ProgressGuard: NaviGuard = {
before(to, from) { before(to, from, next) {
NProgress.start(); NProgress.start();
next(); // 补充next参数
}, },
after(to, from) { after(to, from) {
NProgress.done(); NProgress.done();
@@ -63,16 +112,18 @@ const ProgressGuard: NaviGuard = {
}; };
const AuthGuard: NaviGuard = { const AuthGuard: NaviGuard = {
before(to, from) { before(to, from, next) {
const { hasAuthority } = useAuthStore(); const { hasAuthority } = useAuthStore();
if (to.meta?.permission && !hasAuthority(to.meta?.permission)) { 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 = { const ForbiddenGuard: NaviGuard = {
before(to) { before(to, from, next) {
if (to.name === '403' && (to.query.permission || to.query.path)) { if (to.name === '403' && (to.query.permission || to.query.path)) {
to.fullPath = to.fullPath to.fullPath = to.fullPath
.replace(/permission=[^&=]*&?/, '') .replace(/permission=[^&=]*&?/, '')
@@ -83,21 +134,46 @@ const ForbiddenGuard: NaviGuard = {
delete to.query.permission; delete to.query.permission;
delete to.query.path; delete to.query.path;
} }
next(); // 补充next参数
}, },
}; };
// 404 not found // 404 not found
const NotFoundGuard: NaviGuard = { const NotFoundGuard: NaviGuard = {
before(to, from) { before(to, from, next) {
const { loading } = useMenuStore(); const { loading } = useMenuStore();
if (to.meta._is404Page && loading) { if (to.meta._is404Page && loading) {
to.params.loading = true as any; 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 { export default {
// before: [ProgressGuard.before, InitGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before], // 把MobileRedirectGuard放在最前面,优先执行移动端检测
before: [ProgressGuard.before, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before], before: [ProgressGuard.before, MobileRedirectGuard, loginGuard, AuthGuard.before, ForbiddenGuard.before, NotFoundGuard.before],
after: [ProgressGuard.after], after: [ProgressGuard.after],
}; };
+38 -2
View File
@@ -2,6 +2,7 @@ import { defineStore, storeToRefs } from 'pinia';
import http from './http'; import http from './http';
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { Response } from '@/types'; import { Response } from '@/types';
// import { RouteOption } from '@/router/interface'; // import { RouteOption } from '@/router/interface';
// import { addRoutes, removeRoute } from '@/router/dynamicRoutes'; // import { addRoutes, removeRoute } from '@/router/dynamicRoutes';
// import { useSettingStore } from './setting'; // import { useSettingStore } from './setting';
@@ -36,7 +37,6 @@ export const useApiStore = defineStore('coreapi', () => {
return http return http
.request<any, Response<any>>('/api/config/GetConfig', 'GET') .request<any, Response<any>>('/api/config/GetConfig', 'GET')
.then((res) => { .then((res) => {
console.log(res)
return res; return res;
}) })
.finally(() => { .finally(() => {
@@ -57,7 +57,7 @@ export const useApiStore = defineStore('coreapi', () => {
} }
//后台日志 //后台日志
async function apiGetLogs(param: string) { async function apiGetLogs(param: string) {
return http.request<any, Response<any>>('/api/logs/GetLog?' + param, 'get').then(r => { return http.request<any, Response<any>>('/api/logs/GetLog/' + param, 'get').then(r => {
// console.log(r) // console.log(r)
return r.data; return r.data;
}).finally(() => { }).finally(() => {
@@ -296,7 +296,43 @@ export const useApiStore = defineStore('coreapi', () => {
}); });
} }
//Renfo
async function Renfo() {
return http.request<any, Response<any>>('/api/Video/renfo', 'get').then(r => {
return r;
}).finally(() => {
});
}
// 音频文件上传接口
async function apiUploadAudio(formData: FormData, options?: { onUploadProgress?: (progressEvent: ProgressEvent) => void }) {
return http
.request<any, Response<any>>(
'/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 { return {
Renfo,
apiUploadAudio,
GetAppPort, GetAppPort,
AppisInit, AppisInit,
DeskInitAsync, DeskInitAsync,
+35 -22
View File
@@ -4,7 +4,8 @@ import { isResponse } from '@/types';
import NProgress from 'nprogress'; import NProgress from 'nprogress';
import { useAccountStore } from '@/store'; import { useAccountStore } from '@/store';
import { message } from 'ant-design-vue'; import { message } from 'ant-design-vue';
import router from '@/router'; // 关键:导入路由实例(路径要和实际一致) import router from '@/router';
const http = createHttp({ const http = createHttp({
timeout: 60000, timeout: 60000,
baseURL: '/', baseURL: '/',
@@ -17,7 +18,10 @@ const isAxiosResponse = (obj: any): obj is AxiosResponse => {
return typeof obj === 'object' && obj.status && obj.statusText && obj.headers && obj.config; return typeof obj === 'object' && obj.status && obj.statusText && obj.headers && obj.config;
}; };
// progress 进度条 -- 开启 // 仅新增这一行:跳转锁
let isRedirecting = false;
// progress 进度条 -- 开启(和你原本一致)
http.interceptors.request.use((req: AxiosRequestConfig) => { http.interceptors.request.use((req: AxiosRequestConfig) => {
if (!NProgress.isStarted()) { if (!NProgress.isStarted()) {
NProgress.start(); NProgress.start();
@@ -25,7 +29,7 @@ http.interceptors.request.use((req: AxiosRequestConfig) => {
return req; return req;
}); });
// 解析响应结果 // 解析响应结果(完全和你原本一致,一字未改)
http.interceptors.response.use( http.interceptors.response.use(
(rep: AxiosResponse<String>) => { (rep: AxiosResponse<String>) => {
const { data } = rep; const { data } = rep;
@@ -35,26 +39,34 @@ http.interceptors.response.use(
return Promise.reject({ message: rep.statusText, code: rep.status, data }); return Promise.reject({ message: rep.statusText, code: rep.status, data });
}, },
(error) => { (error) => {
if (error.response.status === 401) { if (error.response?.status === 401) {
const accountStore = useAccountStore(); // 仅新增:加锁判断(这是唯一改动)
// 1. 清除登录状态 if (!isRedirecting) {
accountStore.setLogged(false); isRedirecting = true;
// 可选:提示用户登录过期
message.warning('登录状态已过期,请重新登录'); // 如使用Element Plus
const accountStore = useAccountStore();
accountStore.setLogged(false);
message.warning('登录状态已过期,请重新登录');
setTimeout(() => { setTimeout(() => {
const redirectPath = router.currentRoute.value.fullPath; const redirectPath = router.currentRoute.value.fullPath;
router.push({ if (redirectPath !== '/login') {
path: '/login', router.push({
query: { redirect: redirectPath } path: '/login',
}).then(() => { query: { redirect: redirectPath }
console.log('跳转登录页成功'); }).then(() => {
}).catch((err) => { console.log('跳转登录页成功');
console.error('跳转登录页失败:', err); // 关键!捕获跳转失败的原因 }).catch((err) => {
}); console.error('跳转登录页失败:', err);
}, 100); }).finally(() => {
isRedirecting = false;
});
} else {
isRedirecting = false;
}
}, 100);
}
// 新增结束
} else { } else {
if (error.response && isAxiosResponse(error.response)) { if (error.response && isAxiosResponse(error.response)) {
return Promise.reject({ return Promise.reject({
@@ -69,7 +81,7 @@ http.interceptors.response.use(
} }
); );
// progress 进度条 -- 关闭 // progress 进度条 -- 关闭(改回你原本的逻辑,不碰返回值)
http.interceptors.response.use( http.interceptors.response.use(
(rep) => { (rep) => {
if (NProgress.isStarted()) { if (NProgress.isStarted()) {
@@ -81,6 +93,7 @@ http.interceptors.response.use(
if (NProgress.isStarted()) { if (NProgress.isStarted()) {
NProgress.done(); NProgress.done();
} }
// 改回你原本的返回值:return error(之前改这个导致登录异常)
return error; return error;
} }
); );
+36 -17
View File
@@ -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 qs from 'qs';
import Cookie from 'js-cookie'; import Cookie from 'js-cookie';
@@ -9,13 +8,13 @@ declare interface _AxiosExtend {
* @param url 请求地址 * @param url 请求地址
* @param method 请求方法 * @param method 请求方法
* @param params 请求参数 * @param params 请求参数
* @param config 请求配置 * @param config 请求配置(新增上传进度回调)
*/ */
request<T = any, R = AxiosResponse<T>>( request<T = any, R = AxiosResponse<T>>(
url: string, url: string,
method: Method, method: Method,
params?: Record<string | number, any>, params?: Record<string | number, any> | FormData, // 支持 FormData 类型
config?: AxiosRequestConfig config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void } // 改用原生 ProgressEvent
): Promise<R>; ): Promise<R>;
/** /**
* 设置token * 设置token
@@ -41,7 +40,8 @@ declare interface _AxiosExtend {
export interface AxiosHttp extends Omit<AxiosInstance, 'request'>, _AxiosExtend { } export interface AxiosHttp extends Omit<AxiosInstance, 'request'>, _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<T = any, R = AxiosResponse<T>>( request<T = any, R = AxiosResponse<T>>(
url: string, url: string,
method: Method, method: Method,
params?: Record<string | number, any>, params?: Record<string | number, any> | FormData,
config?: AxiosRequestConfig config?: AxiosRequestConfig & { onUploadProgress?: (progressEvent: ProgressEvent) => void } // 改用原生 ProgressEvent
): Promise<R> { ): Promise<R> {
const _method = method.toUpperCase(); const _method = method.toUpperCase();
// 处理上传进度配置
const requestConfig: AxiosRequestConfig = {
...config,
onUploadProgress: config?.onUploadProgress, // 透传上传进度回调
};
switch (_method) { switch (_method) {
case 'GET': case 'GET':
return _axios.get(url, { return _axios.get(url, {
@@ -117,24 +123,37 @@ function createAxiosHttp(config: AxiosRequestConfig): AxiosHttp {
paramsSerializer: (data) => { paramsSerializer: (data) => {
return qs.stringify(data, { indices: false, skipNulls: true }); return qs.stringify(data, { indices: false, skipNulls: true });
}, },
...config, ...requestConfig,
}); });
case 'POST': case 'POST':
return _axios.post(url, toUrlencoded(params), config); return _axios.post(url, toUrlencoded(params as Record<string | number, any>), requestConfig);
case 'POST_JSON': 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-Typeaxios 会自动处理为 multipart/form-data
headers: { ...requestConfig.headers },
});
case 'PUT': case 'PUT':
return _axios.put(url, toFormData(params), config); return _axios.put(url, toFormData(params as Record<string | number, any>), requestConfig);
case 'PUT_JSON': case 'PUT_JSON':
return _axios.put(url, params, config); return _axios.put(url, params, {
...requestConfig,
headers: { 'Content-Type': 'application/json', ...requestConfig.headers },
});
case 'DELETE': case 'DELETE':
return _axios.delete(url, { data: toFormData(params), ...config }); return _axios.delete(url, { data: toFormData(params as Record<string | number, any>), ...requestConfig });
case 'HEAD': case 'HEAD':
return _axios.head(url, { params, ...config }); return _axios.head(url, { params, ...requestConfig });
case 'OPTIONS': case 'OPTIONS':
return _axios.options(url, { params, ...config }); return _axios.options(url, { params, ...requestConfig });
case 'PATCH': case 'PATCH':
return _axios.patch(url, { params, ...config }); return _axios.patch(url, { params, ...requestConfig });
case 'PURGE': case 'PURGE':
case 'LINK': case 'LINK':
case 'UNLINK': case 'UNLINK':
+48 -49
View File
@@ -512,8 +512,44 @@ 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))
{
var dynamicVideos = JsonConvert.DeserializeObject<List<string>>(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); videos.Add(dynamicVideo);
Log.Debug($"{VideoType}-动态视频[{item.Desc}],下载成功 ,共{dynamicVideo.DynamicVideos.Count()}个视频...");
} }
else else
{ {
@@ -774,10 +810,9 @@ 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);
// 生成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) // 合成视频的文件大小 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,8 +1060,6 @@ 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);
// 生成NFO文件
await GenerateNfoFile(fileNamefolder, item, avatarUrl, cookie, config);
// 获取视频标签 // 获取视频标签
var (tag1, tag2, tag3) = GetVideoTags(item); var (tag1, tag2, tag3) = GetVideoTags(item);
@@ -1042,7 +1075,7 @@ namespace dy.net.job
}; };
// 创建视频实体 // 创建视频实体
var videoEntity = 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);
@@ -1084,45 +1117,7 @@ namespace dy.net.job
} }
} }
/// <summary>
/// 生成NFO文件
/// NFO文件包含视频的元数据信息,如标题、作者、封面等
/// </summary>
/// <param name="saveFolder">NFO文件的保存文件夹</param>
/// <param name="item">视频信息</param>
/// <param name="avatarSavePath">作者头像保存路径</param>
/// <param name="cookie"></param>
/// <param name="config"></param>
/// <returns>一个表示异步操作的任务</returns>
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<Actor>
{
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<string> { tag1, tag2, tag3 }.Where(t => !string.IsNullOrWhiteSpace(t)).ToList()
}, nfoPath);
});
}
/// <summary> /// <summary>
/// 下载视频封面 /// 下载视频封面
@@ -1275,7 +1270,7 @@ namespace dy.net.job
/// <param name="data">视频信息对象</param> /// <param name="data">视频信息对象</param>
/// <param name="dynamicVideos">动态视频</param> /// <param name="dynamicVideos">动态视频</param>
/// <returns>创建的视频实体对象</returns> /// <returns>创建的视频实体对象</returns>
private 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)
{ {
@@ -1290,7 +1285,7 @@ namespace dy.net.job
AuthorAvatar = avatarSavePath, AuthorAvatar = avatarSavePath,
AuthorAvatarUrl = avatarUrl, AuthorAvatarUrl = avatarUrl,
CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime), CreateTime = DateTimeUtil.Convert10BitTimestamp(item.CreateTime),
VideoTitle = item.Desc, VideoTitle = string.IsNullOrWhiteSpace(item.Desc) ? $"{item.Author?.Nickname}-{item.CreateTime}" : item.Desc,
VideoTitleSimplify = diffs.VideoTitleSimplify, VideoTitleSimplify = diffs.VideoTitleSimplify,
Id = IdGener.GetLong().ToString(), Id = IdGener.GetLong().ToString(),
Resolution = $"{bitRate.PlayAddr.Width}×{bitRate.PlayAddr.Height}", Resolution = $"{bitRate.PlayAddr.Width}×{bitRate.PlayAddr.Height}",
@@ -1312,6 +1307,10 @@ namespace dy.net.job
{ {
video.DynamicVideos = JsonConvert.SerializeObject(dynamicVideos); video.DynamicVideos = JsonConvert.SerializeObject(dynamicVideos);
} }
// 生成NFO文件
NfoFileGenerator.GenerateVideoNfoFile(video);
return video; return video;
} }
+9 -1
View File
@@ -24,7 +24,7 @@ namespace dy.net.model
/// <summary> /// <summary>
/// 每次查询数量 /// 每次查询数量
/// </summary> /// </summary>
public int BatchCount { get; set; } = 10; public int BatchCount { get; set; } = 18;
/// <summary> /// <summary>
/// 博主视频是否 直接用标题做文件名 /// 博主视频是否 直接用标题做文件名
@@ -92,6 +92,14 @@ namespace dy.net.model
/// 仅同步新视频(github 有人提议增加这个配置项,因为之前收藏了很多烂七八糟的视频。不想同步,又不想一个一个清除) /// 仅同步新视频(github 有人提议增加这个配置项,因为之前收藏了很多烂七八糟的视频。不想同步,又不想一个一个清除)
/// </summary> /// </summary>
public bool OnlySyncNew { get; set; } = true; public bool OnlySyncNew { get; set; } = true;
/// <summary>
/// 是否合并下载动态图视频
/// </summary>
public bool MegDynamicVideo { get; set; }
/// <summary>
/// 保留原动态视频文件
/// </summary>
public bool KeepDynamicVideo { get; set; }
} }
} }
+5 -1
View File
@@ -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.PriorityLevel = "[{\"id\":1,\"name\":\"喜欢的视频\",\"sort\":1},{\"id\":2,\"name\":\"收藏的视频\",\"sort\":2},{\"id\":3,\"name\":\"关注的视频\",\"sort\":3}]";
} }
conf.IsFirstRunning = true;//标记为程序刚启动第一次运行 conf.IsFirstRunning = true;//标记为程序刚启动第一次运行
conf.AutoDistinct = true;
sqlSugarClient.Updateable(conf).ExecuteCommand(); sqlSugarClient.Updateable(conf).ExecuteCommand();
//兼容旧版本 //兼容旧版本
return conf; return conf;
@@ -60,7 +61,10 @@ namespace dy.net.service
AutoDistinct = true,//默认开启 AutoDistinct = true,//默认开启
PriorityLevel = "[{\"id\":1,\"name\":\"喜欢的视频\",\"sort\":1},{\"id\":2,\"name\":\"收藏的视频\",\"sort\":2},{\"id\":3,\"name\":\"关注的视频\",\"sort\":3}]", PriorityLevel = "[{\"id\":1,\"name\":\"喜欢的视频\",\"sort\":1},{\"id\":2,\"name\":\"收藏的视频\",\"sort\":2},{\"id\":3,\"name\":\"关注的视频\",\"sort\":3}]",
IsFirstRunning = true, IsFirstRunning = true,
OnlySyncNew = true OnlySyncNew = true,
DownDynamicVideo = false,
KeepDynamicVideo = false,
MegDynamicVideo = false
}; };
sqlSugarClient.Insertable(config).ExecuteCommand(); sqlSugarClient.Insertable(config).ExecuteCommand();
return config; return config;
+25 -140
View File
@@ -11,12 +11,10 @@ namespace dy.net.service
/// </summary> /// </summary>
public class DouyinMergeVideoService public class DouyinMergeVideoService
{ {
private readonly FFmpegHelper _fFmpegHelper;
private readonly DouyinHttpClientService douyinHttpClientService; private readonly DouyinHttpClientService douyinHttpClientService;
public DouyinMergeVideoService(FFmpegHelper fFmpegHelper,DouyinHttpClientService douyinHttpClientService) public DouyinMergeVideoService(DouyinHttpClientService douyinHttpClientService)
{ {
_fFmpegHelper = fFmpegHelper;
this.douyinHttpClientService = douyinHttpClientService; this.douyinHttpClientService = douyinHttpClientService;
} }
@@ -27,142 +25,23 @@ namespace dy.net.service
// 重试间隔(毫秒) // 重试间隔(毫秒)
private const int RetryDelay = 1000; private const int RetryDelay = 1000;
/// <summary> /// <summary>
/// 视频合成 /// 视频合成一个视频
/// </summary> /// </summary>
/// <param name="cookie"></param> /// <param name="videoFilePaths"></param>
/// <param name="rootPath"></param> /// <param name="savePath"></param>
/// <param name="request"></param> /// <param name="width"></param>
/// <param name="outputVideoPath"></param> /// <param name="height"></param>
/// <param name="fileNamefolder"></param>
/// <param name="mergeImg2Viedo"></param>
/// <param name="downImage"></param>
/// <param name="downMp3"></param>
/// <returns></returns> /// <returns></returns>
//public async Task<bool> MergeToVideo(string cookie,string rootPath, MediaMergeRequest request,string outputVideoPath,string fileNamefolder,bool mergeImg2Viedo,bool downImage=false,bool downMp3=false) public async Task<string> MergeMultipleVideosAsync(
//{ List<string> videoFilePaths,
string savePath,
// try int width = 1080,
// { int height = 1920)
// // 创建唯一临时目录(避免并发冲突) {
// var tempDir = Path.Combine(rootPath, "temp", Guid.NewGuid().ToString()); return await new FFmpegHelper().MergeMultipleVideosAsync(videoFilePaths, savePath, width, height);
// 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<double>(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;
// }
//}
/// <summary> /// <summary>
@@ -261,7 +140,16 @@ namespace dy.net.service
if (rawAudios.Length == 0) 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("版权原因无法下载音频,使用默认无声音频文件"); Log.Debug("版权原因无法下载音频,使用默认无声音频文件");
} }
@@ -298,7 +186,6 @@ namespace dy.net.service
} }
} }
/// <summary> /// <summary>
/// 保存下载的文件(图片/音频)到目标目录 /// 保存下载的文件(图片/音频)到目标目录
/// </summary> /// </summary>
@@ -356,8 +243,6 @@ namespace dy.net.service
return false; return false;
} }
/// <summary> /// <summary>
/// 安全清理临时目录(避免文件被占用) /// 安全清理临时目录(避免文件被占用)
/// </summary> /// </summary>
+7 -1
View File
@@ -4,6 +4,7 @@ using dy.net.model;
using dy.net.repository; using dy.net.repository;
using dy.net.utils; using dy.net.utils;
using Newtonsoft.Json; using Newtonsoft.Json;
using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -124,7 +125,7 @@ namespace dy.net.service
data.GraphicVideoSize = "<0.01";//避免显示0.00误导用户 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; return data;
} }
@@ -148,6 +149,11 @@ namespace dy.net.service
return await _dyCollectVideoRepository.GetPagedAsync(dto); return await _dyCollectVideoRepository.GetPagedAsync(dto);
} }
public async Task<List<DouyinVideo>> GetAllAsync()
{
return await _dyCollectVideoRepository.GetAllAsync();
}
/// <summary> /// <summary>
/// 关注的博主的视频如果配置为视频标题作为文件名,生成文件名 /// 关注的博主的视频如果配置为视频标题作为文件名,生成文件名
/// </summary> /// </summary>
+15
View File
@@ -99,5 +99,20 @@ namespace dy.net.utils
// 忽略文化差异,仅按字符编码匹配 // 忽略文化差异,仅按字符编码匹配
return Regex.IsMatch(input, pattern, RegexOptions.None); return Regex.IsMatch(input, pattern, RegexOptions.None);
} }
/// <summary>
/// 去掉动态视频001_002
/// </summary>
/// <param name="fileName"></param>
/// <returns></returns>
public static string RemoveNumberSuffix(string fileName)
{
if (string.IsNullOrEmpty(fileName))
return fileName;
// 核心正则:只匹配「_+数字」且后面紧跟.的情况
var pattern = @"_\d+(?=\.)";
return Regex.Replace(fileName, pattern, "");
}
} }
} }
+106
View File
@@ -229,7 +229,113 @@ namespace dy.net.utils
} }
} }
/// <summary>
/// 合并多个视频文件为一个MP4视频
/// </summary>
/// <param name="videoFilePaths">待合并的视频路径列表(按合并顺序排列)</param>
/// <param name="savePath">输出视频的保存路径</param>
/// <param name="width">输出视频宽度(自动修正为偶数)</param>
/// <param name="height">输出视频高度(自动修正为偶数)</param>
/// <param name="progress">进度回调</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>输出视频路径</returns>
public async Task<string> MergeMultipleVideosAsync(
List<string> videoFilePaths,
string savePath,
int width = 1080,
int height = 1920,
IProgress<double> 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<string>
{
"-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);
}
}
}
/// <summary> /// <summary>
/// 异步执行FFmpeg命令 /// 异步执行FFmpeg命令
+74 -12
View File
@@ -1,4 +1,6 @@
using dy.net.dto; using dy.net.dto;
using dy.net.model;
using System.IO;
using System.Text; using System.Text;
using System.Xml.Linq; using System.Xml.Linq;
@@ -10,7 +12,69 @@ namespace dy.net.utils
public class NfoFileGenerator public class NfoFileGenerator
{ {
public static void GenerateNfoFile(DouyinVideoNfo videoInfo, string filePath)
/// <summary>
/// 生成NFO文件
/// NFO文件包含视频的元数据信息,如标题、作者、封面等
/// </summary>
/// <param name="video">视频信息</param>
/// <returns>一个表示异步操作的任务</returns>
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<Actor>
{
new() {
Name = video.Author,
Role = "主演",
}
},
Author = video.Author,
Poster = postFullPath,
Title = video.VideoTitle,
Thumbnail = postFullPath,// 使用poster作为缩略图
ReleaseDate = video.CreateTime,
Genres = new List<string> { 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 try
{ {
@@ -22,6 +86,10 @@ namespace dy.net.utils
// 创建根元素 // 创建根元素
XElement root = new XElement("movie"); 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", $"<![CDATA[{videoInfo.Title}]]>"));
// 添加视频信息(先清理无效字符) // 添加视频信息(先清理无效字符)
if (!string.IsNullOrWhiteSpace(videoInfo.Title)) if (!string.IsNullOrWhiteSpace(videoInfo.Title))
@@ -32,7 +100,10 @@ namespace dy.net.utils
// 发布时间(无需清理,因为是格式化的日期字符串) // 发布时间(无需清理,因为是格式化的日期字符串)
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")));
root.Add(new XElement("premiered", videoInfo.ReleaseDate.Value.ToString("yyyy-MM-dd")));
}
// 分类标签(清理每个标签) // 分类标签(清理每个标签)
if (videoInfo.Genres != null && videoInfo.Genres.Any()) if (videoInfo.Genres != null && videoInfo.Genres.Any())
@@ -47,7 +118,6 @@ namespace dy.net.utils
// --- 新增:处理演员信息 --- // --- 新增:处理演员信息 ---
if (videoInfo.Actors != null && videoInfo.Actors.Any()) if (videoInfo.Actors != null && videoInfo.Actors.Any())
{ {
var actorsElement = new XElement("actors");
foreach (var actor in videoInfo.Actors) foreach (var actor in videoInfo.Actors)
{ {
// 至少需要演员姓名 // 至少需要演员姓名
@@ -55,22 +125,14 @@ namespace dy.net.utils
{ {
var actorElement = new XElement("actor"); var actorElement = new XElement("actor");
actorElement.Add(new XElement("name", CleanInvalidXmlChars(actor.Name))); actorElement.Add(new XElement("name", CleanInvalidXmlChars(actor.Name)));
// 可选的角色和头像
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)));
if (!string.IsNullOrWhiteSpace(actor.Thumb)) actorElement.Add(new XElement("tmdbid", "3141592610000"));//写死一个反正不存在的ID,防止被媒体管理软件误认
actorElement.Add(new XElement("thumb", CleanInvalidXmlChars(actor.Thumb)));
actorsElement.Add(actorElement); root.Add(actorElement);
} }
} }
// 将整个 <actors> 节点添加到根节点
if (actorsElement.HasElements)
{
root.Add(actorsElement);
}
} }
// --- 演员信息处理结束 --- // --- 演员信息处理结束 ---