commit 3f58700b38f4aa95826591f65a5ed3bc98a5886d Author: jiumikeji <929832497@qq.com> Date: Tue Apr 22 19:56:52 2025 +0800 jiumi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f55ffd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target/ +/.idea/ +/logs/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dc9739c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 panll + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c748027 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# wvp-pro-assist + +wvp-pro-assist是wvp-pro的辅助录像程序,也可单独跟zlm一起使用,提供录像控制,录像合并下载接口 diff --git a/lib-arm/ffmpeg b/lib-arm/ffmpeg new file mode 100644 index 0000000..fa7906e Binary files /dev/null and b/lib-arm/ffmpeg differ diff --git a/lib-arm/ffprobe b/lib-arm/ffprobe new file mode 100644 index 0000000..7e38ff7 Binary files /dev/null and b/lib-arm/ffprobe differ diff --git a/lib/ffmpeg b/lib/ffmpeg new file mode 100644 index 0000000..13e56c8 Binary files /dev/null and b/lib/ffmpeg differ diff --git a/lib/ffprobe b/lib/ffprobe new file mode 100644 index 0000000..e6bd137 Binary files /dev/null and b/lib/ffprobe differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..166fe08 --- /dev/null +++ b/pom.xml @@ -0,0 +1,121 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.2 + + top.panll.assist + wvp-pro-assist + 2.6.9 + wvp-pro-assist + + + 1.8 + MMddHHmm + + + + + + nexus-aliyun + Nexus aliyun + https://maven.aliyun.com/repository/public + default + + false + + + true + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-redis + + + + net.bramp.ffmpeg + ffmpeg + 0.6.2 + + + + + com.alibaba + fastjson + 1.2.73 + + + + + org.springdoc + springdoc-openapi-ui + 1.6.10 + + + com.github.xiaoymin + knife4j-springdoc-ui + 3.0.3 + + + + org.mp4parser + muxer + 1.9.56 + + + org.mp4parser + streaming + 1.9.56 + + + + org.mp4parser + isoparser + 1.9.27 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + ${project.artifactId}-${project.version}-${maven.build.timestamp} + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + diff --git a/src/main/java/top/panll/assist/WvpProAssistApplication.java b/src/main/java/top/panll/assist/WvpProAssistApplication.java new file mode 100644 index 0000000..c19cadf --- /dev/null +++ b/src/main/java/top/panll/assist/WvpProAssistApplication.java @@ -0,0 +1,15 @@ +package top.panll.assist; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class WvpProAssistApplication { + + public static void main(String[] args) { + SpringApplication.run(WvpProAssistApplication.class, args); + } + +} diff --git a/src/main/java/top/panll/assist/config/FastJsonRedisSerializer.java b/src/main/java/top/panll/assist/config/FastJsonRedisSerializer.java new file mode 100644 index 0000000..1d896c4 --- /dev/null +++ b/src/main/java/top/panll/assist/config/FastJsonRedisSerializer.java @@ -0,0 +1,44 @@ +package top.panll.assist.config; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.parser.ParserConfig; +import com.alibaba.fastjson.serializer.SerializerFeature; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.SerializationException; + +import java.nio.charset.Charset; + +public class FastJsonRedisSerializer implements RedisSerializer { + private static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); + private Class clazz; + + /** + * 添加autotype白名单 + * 解决redis反序列化对象时报错 :com.alibaba.fastjson.JSONException: autoType is not support + */ + static { + ParserConfig.getGlobalInstance().addAccept("top.panll.assist"); + } + + public FastJsonRedisSerializer(Class clazz) { + super(); + this.clazz = clazz; + } + + @Override + public byte[] serialize(T t) throws SerializationException { + if (null == t) { + return new byte[0]; + } + return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET); + } + + @Override + public T deserialize(byte[] bytes) throws SerializationException { + if (null == bytes || bytes.length <= 0) { + return null; + } + String str = new String(bytes, DEFAULT_CHARSET); + return JSON.parseObject(str, clazz); + } +} diff --git a/src/main/java/top/panll/assist/config/GlobalExceptionHandler.java b/src/main/java/top/panll/assist/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..2a9d934 --- /dev/null +++ b/src/main/java/top/panll/assist/config/GlobalExceptionHandler.java @@ -0,0 +1,44 @@ +package top.panll.assist.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import top.panll.assist.controller.bean.ControllerException; +import top.panll.assist.controller.bean.ErrorCode; +import top.panll.assist.controller.bean.WVPResult; + +/** + * 全局异常处理 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private final static Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 默认异常处理 + * @param e 异常 + * @return 统一返回结果 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public WVPResult exceptionHandler(Exception e) { + logger.error("[全局异常]: ", e); + return WVPResult.fail(ErrorCode.ERROR500.getCode(), e.getMessage()); + } + + /** + * 自定义异常处理, 处理controller中返回的错误 + * @param e 异常 + * @return 统一返回结果 + */ + @ExceptionHandler(ControllerException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public WVPResult exceptionHandler(ControllerException e) { + return WVPResult.fail(e.getCode(), e.getMsg()); + } + +} diff --git a/src/main/java/top/panll/assist/config/GlobalResponseAdvice.java b/src/main/java/top/panll/assist/config/GlobalResponseAdvice.java new file mode 100644 index 0000000..52ee2d8 --- /dev/null +++ b/src/main/java/top/panll/assist/config/GlobalResponseAdvice.java @@ -0,0 +1,54 @@ +package top.panll.assist.config; + +import com.alibaba.fastjson.JSON; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; +import top.panll.assist.controller.bean.ErrorCode; +import top.panll.assist.controller.bean.WVPResult; + +import javax.validation.constraints.NotNull; + +/** + * 全局统一返回结果 + * @author lin + */ +@RestControllerAdvice +public class GlobalResponseAdvice implements ResponseBodyAdvice { + + + @Override + public boolean supports(@NotNull MethodParameter returnType, @NotNull Class> converterType) { + return true; + } + + @Override + public Object beforeBodyWrite(Object body, @NotNull MethodParameter returnType, @NotNull MediaType selectedContentType, @NotNull Class> selectedConverterType, @NotNull ServerHttpRequest request, @NotNull ServerHttpResponse response) { + // 排除api文档的接口,这个接口不需要统一 + String[] excludePath = {"/v3/api-docs","/api/v1","/index/hook"}; + for (String path : excludePath) { + if (request.getURI().getPath().startsWith(path)) { + return body; + } + } + + if (body instanceof WVPResult) { + return body; + } + + if (body instanceof ErrorCode) { + ErrorCode errorCode = (ErrorCode) body; + return new WVPResult<>(errorCode.getCode(), errorCode.getMsg(), null); + } + + if (body instanceof String) { + return JSON.toJSONString(WVPResult.success(body)); + } + + return WVPResult.success(body); + } +} diff --git a/src/main/java/top/panll/assist/config/RedisConfig.java b/src/main/java/top/panll/assist/config/RedisConfig.java new file mode 100644 index 0000000..1866a23 --- /dev/null +++ b/src/main/java/top/panll/assist/config/RedisConfig.java @@ -0,0 +1,66 @@ +package top.panll.assist.config; + +import com.alibaba.fastjson.parser.ParserConfig; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * @Description:Redis中间件配置类,使用spring-data-redis集成,自动从application.yml中加载redis配置 + * @author: swwheihei + * @date: 2019年5月30日 上午10:58:25 + * + */ +@Configuration +@ConditionalOnClass(RedisOperations.class) +@EnableConfigurationProperties(RedisProperties.class) +public class RedisConfig { + + static { + ParserConfig.getGlobalInstance().addAccept("top.panll.assist"); + } + + @Bean + @ConditionalOnMissingBean(name = "redisTemplate") + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(redisConnectionFactory); + // 使用fastjson进行序列化处理,提高解析效率 + FastJsonRedisSerializer serializer = new FastJsonRedisSerializer<>(Object.class); + // value值的序列化采用fastJsonRedisSerializer + template.setValueSerializer(serializer); + template.setHashValueSerializer(serializer); + // key的序列化采用StringRedisSerializer + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setConnectionFactory(redisConnectionFactory); + // 使用fastjson时需设置此项,否则会报异常not support type +// ParserConfig.getGlobalInstance().setAutoTypeSupport(true); + return template; + + } + + /** + * redis消息监听器容器 可以添加多个监听不同话题的redis监听器,只需要把消息监听器和相应的消息订阅处理器绑定,该消息监听器 + * 通过反射技术调用消息订阅处理器的相关方法进行一些业务处理 + * + * @param connectionFactory + * @return + */ + @Bean + RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) { + + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + return container; + } + +} diff --git a/src/main/java/top/panll/assist/config/SpringDocConfig.java b/src/main/java/top/panll/assist/config/SpringDocConfig.java new file mode 100644 index 0000000..a04481d --- /dev/null +++ b/src/main/java/top/panll/assist/config/SpringDocConfig.java @@ -0,0 +1,45 @@ +package top.panll.assist.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import org.springdoc.core.GroupedOpenApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author lin + */ +@Configuration +public class SpringDocConfig { + + @Value("${doc.enabled: true}") + private boolean enable; + + @Bean + public OpenAPI springShopOpenApi() { + Contact contact = new Contact(); + contact.setName("pan"); + contact.setEmail("648540858@qq.com"); + return new OpenAPI() + .info(new Info().title("WVP-PRO-ASSIST 接口文档") + .contact(contact) + .description("WVP-PRO助手,补充ZLM功能") + .version("v2.0") + .license(new License().name("Apache 2.0").url("http://springdoc.org"))); + } + + /** + * 添加分组 + * @return + */ + @Bean + public GroupedOpenApi publicApi() { + return GroupedOpenApi.builder() + .group("1. 全部") + .packagesToScan("top.panll.assist") + .build(); + } +} diff --git a/src/main/java/top/panll/assist/config/ThreadPoolTaskConfig.java b/src/main/java/top/panll/assist/config/ThreadPoolTaskConfig.java new file mode 100644 index 0000000..e0cde2f --- /dev/null +++ b/src/main/java/top/panll/assist/config/ThreadPoolTaskConfig.java @@ -0,0 +1,59 @@ +package top.panll.assist.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +@Configuration +@EnableAsync(proxyTargetClass = true) +public class ThreadPoolTaskConfig { + + public static final int cpuNum = Runtime.getRuntime().availableProcessors(); + + /** + * 默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务, + * 当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中; + * 当队列满了,就继续创建线程,当线程数量大于等于maxPoolSize后,开始使用拒绝策略拒绝 + */ + + /** + * 核心线程数(默认线程数) + */ + private static final int corePoolSize = cpuNum; + /** + * 最大线程数 + */ + private static final int maxPoolSize = cpuNum*2; + /** + * 允许线程空闲时间(单位:默认为秒) + */ + private static final int keepAliveTime = 30; + /** + * 缓冲队列大小 + */ + private static final int queueCapacity = 500; + /** + * 线程池名前缀 + */ + private static final String threadNamePrefix = "wvp-assist-"; + + @Bean("taskExecutor") // bean的名称,默认为首字母小写的方法名 + public ThreadPoolTaskExecutor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setKeepAliveSeconds(keepAliveTime); + executor.setThreadNamePrefix(threadNamePrefix); + + // 线程池对拒绝任务的处理策略 + // CallerRunsPolicy:由调用线程(提交任务的线程)处理该任务 + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + // 初始化 + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/top/panll/assist/config/WebMvcConfig.java b/src/main/java/top/panll/assist/config/WebMvcConfig.java new file mode 100644 index 0000000..23a2c6c --- /dev/null +++ b/src/main/java/top/panll/assist/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package top.panll.assist.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter; +import top.panll.assist.dto.UserSettings; + +import java.io.File; + + +@Configuration +public class WebMvcConfig extends WebMvcConfigurerAdapter { + + @Autowired + private UserSettings userSettings; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + File file = new File(userSettings.getRecordTempPath()); + registry.addResourceHandler("/download/**").addResourceLocations("file://" + file.getAbsolutePath() + "/"); + super.addResourceHandlers(registry); + } +} diff --git a/src/main/java/top/panll/assist/controller/RecordController.java b/src/main/java/top/panll/assist/controller/RecordController.java new file mode 100644 index 0000000..ea1fb8c --- /dev/null +++ b/src/main/java/top/panll/assist/controller/RecordController.java @@ -0,0 +1,130 @@ +package top.panll.assist.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import top.panll.assist.controller.bean.*; +import top.panll.assist.dto.*; +import top.panll.assist.service.VideoFileService; +import top.panll.assist.utils.RedisUtil; + +import java.text.SimpleDateFormat; +import java.util.*; + +@Tag(name = "录像管理", description = "录像管理") +@CrossOrigin +@RestController +@RequestMapping("/api/record") +public class RecordController { + + private final static Logger logger = LoggerFactory.getLogger(RecordController.class); + + @Autowired + private VideoFileService videoFileService; + + @Autowired + private RedisUtil redisUtil; + + @Autowired + private UserSettings userSettings; + + private SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + + /** + * 获取Assist服务配置信息 + */ + @Operation(summary ="获取Assist服务配置信息") + @GetMapping(value = "/info") + @ResponseBody + public UserSettings getInfo(){ + return userSettings; + } + + + /** + * 添加视频裁剪合并任务 + */ + @Operation(summary ="添加视频裁剪合并任务") + @Parameter(name = "videoTaskInfo", description = "视频合并任务的信息", required = true) + @PostMapping(value = "/file/download/task/add") + @ResponseBody + public String addTaskForDownload(@RequestBody VideoTaskInfo videoTaskInfo ){ + if (videoTaskInfo.getFilePathList() == null || videoTaskInfo.getFilePathList().isEmpty()) { + throw new ControllerException(ErrorCode.ERROR100.getCode(), "视频文件列表不可为空"); + } + String id = videoFileService.mergeOrCut(videoTaskInfo); + if (id== null) { + throw new ControllerException(ErrorCode.ERROR100.getCode(), "可能未找到视频文件"); + } + return id; + } + + /** + * 查询视频裁剪合并任务列表 + */ + @Operation(summary ="查询视频裁剪合并任务列表") + @Parameter(name = "taskId", description = "任务ID", required = true) + @Parameter(name = "isEnd", description = "是否结束", required = true) + @GetMapping(value = "/file/download/task/list") + @ResponseBody + public List getTaskListForDownload( + @RequestParam(required = false) String app, + @RequestParam(required = false) String stream, + @RequestParam(required = false) String callId, + @RequestParam(required = false) String taskId, + @RequestParam(required = false) Boolean isEnd){ + List taskList = videoFileService.getTaskListForDownload(app, stream, callId, isEnd, taskId); + if (taskList == null) { + throw new ControllerException(ErrorCode.ERROR100); + } + return taskList; + } + + /** + * 中止视频裁剪合并任务列表 + */ + @Operation(summary ="中止视频裁剪合并任务列表(暂不支持)") + @GetMapping(value = "/file/download/task/stop") + @ResponseBody + public WVPResult stopTaskForDownload(@RequestParam String taskId){ +// WVPResult result = new WVPResult<>(); +// if (taskId == null) { +// result.setCode(400); +// result.setMsg("taskId 不能为空"); +// return result; +// } +// boolean stopResult = videoFileService.stopTask(taskId); +// result.setCode(0); +// result.setMsg(stopResult ? "success": "fail"); + return null; + } + + /** + * 磁盘空间查询 + */ + @Operation(summary ="磁盘空间查询") + @ResponseBody + @GetMapping(value = "/space", produces = "application/json;charset=UTF-8") + public SpaceInfo getSpace() { + return videoFileService.getSpaceInfo(); + } + + /** + * 录像文件的时长 + */ + @Operation(summary ="录像文件的时长") + @Parameter(name = "app", description = "应用名", required = true) + @Parameter(name = "stream", description = "流ID", required = true) + @Parameter(name = "recordIng", description = "是否录制中", required = true) + @ResponseBody + @GetMapping(value = "/file/duration", produces = "application/json;charset=UTF-8") + @PostMapping(value = "/file/duration", produces = "application/json;charset=UTF-8") + public long fileDuration( @RequestParam String app, @RequestParam String stream) { + return videoFileService.fileDuration(app, stream); + } +} diff --git a/src/main/java/top/panll/assist/controller/bean/ControllerException.java b/src/main/java/top/panll/assist/controller/bean/ControllerException.java new file mode 100644 index 0000000..2191f18 --- /dev/null +++ b/src/main/java/top/panll/assist/controller/bean/ControllerException.java @@ -0,0 +1,35 @@ +package top.panll.assist.controller.bean; + +/** + * 自定义异常,controller出现错误时直接抛出异常由全局异常捕获并返回结果 + */ +public class ControllerException extends RuntimeException{ + + private int code; + private String msg; + + public ControllerException(int code, String msg) { + this.code = code; + this.msg = msg; + } + public ControllerException(ErrorCode errorCode) { + this.code = errorCode.getCode(); + this.msg = errorCode.getMsg(); + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMsg() { + return msg; + } + + public void setMsg(String msg) { + this.msg = msg; + } +} diff --git a/src/main/java/top/panll/assist/controller/bean/ErrorCode.java b/src/main/java/top/panll/assist/controller/bean/ErrorCode.java new file mode 100644 index 0000000..0a7d173 --- /dev/null +++ b/src/main/java/top/panll/assist/controller/bean/ErrorCode.java @@ -0,0 +1,29 @@ +package top.panll.assist.controller.bean; + +/** + * 全局错误码 + */ +public enum ErrorCode { + SUCCESS(0, "成功"), + ERROR100(100, "失败"), + ERROR400(400, "参数不全或者错误"), + ERROR403(403, "无权限操作"), + ERROR401(401, "请登录后重新请求"), + ERROR500(500, "系统异常"); + + private final int code; + private final String msg; + + ErrorCode(int code, String msg) { + this.code = code; + this.msg = msg; + } + + public int getCode() { + return code; + } + + public String getMsg() { + return msg; + } +} diff --git a/src/main/java/top/panll/assist/controller/bean/FileLIstInfo.java b/src/main/java/top/panll/assist/controller/bean/FileLIstInfo.java new file mode 100644 index 0000000..2b9790c --- /dev/null +++ b/src/main/java/top/panll/assist/controller/bean/FileLIstInfo.java @@ -0,0 +1,16 @@ +package top.panll.assist.controller.bean; + +import java.util.List; + +public class FileLIstInfo { + + private List filePathList; + + public List getFilePathList() { + return filePathList; + } + + public void setFilePathList(List filePathList) { + this.filePathList = filePathList; + } +} diff --git a/src/main/java/top/panll/assist/controller/bean/RecordFile.java b/src/main/java/top/panll/assist/controller/bean/RecordFile.java new file mode 100644 index 0000000..8f484af --- /dev/null +++ b/src/main/java/top/panll/assist/controller/bean/RecordFile.java @@ -0,0 +1,53 @@ +package top.panll.assist.controller.bean; + +public class RecordFile { + private String app; + private String stream; + + private String fileName; + + private String date; + + + public static RecordFile instance(String app, String stream, String fileName, String date) { + RecordFile recordFile = new RecordFile(); + recordFile.setApp(app); + recordFile.setStream(stream); + recordFile.setFileName(fileName); + recordFile.setDate(date); + return recordFile; + } + + + public String getApp() { + return app; + } + + public void setApp(String app) { + this.app = app; + } + + public String getStream() { + return stream; + } + + public void setStream(String stream) { + this.stream = stream; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getDate() { + return date; + } + + public void setDate(String date) { + this.date = date; + } +} diff --git a/src/main/java/top/panll/assist/controller/bean/WVPResult.java b/src/main/java/top/panll/assist/controller/bean/WVPResult.java new file mode 100644 index 0000000..631677e --- /dev/null +++ b/src/main/java/top/panll/assist/controller/bean/WVPResult.java @@ -0,0 +1,66 @@ +package top.panll.assist.controller.bean; + + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "统一返回结果") +public class WVPResult { + + public WVPResult() { + } + + public WVPResult(int code, String msg, T data) { + this.code = code; + this.msg = msg; + this.data = data; + } + + + @Schema(description = "错误码,0为成功") + private int code; + @Schema(description = "描述,错误时描述错误原因") + private String msg; + @Schema(description = "数据") + private T data; + + + public static WVPResult success(T t, String msg) { + return new WVPResult<>(ErrorCode.SUCCESS.getCode(), msg, t); + } + + public static WVPResult success(T t) { + return success(t, ErrorCode.SUCCESS.getMsg()); + } + + public static WVPResult fail(int code, String msg) { + return new WVPResult<>(code, msg, null); + } + + public static WVPResult fail(ErrorCode errorCode) { + return fail(errorCode.getCode(), errorCode.getMsg()); + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMsg() { + return msg; + } + + public void setMsg(String msg) { + this.msg = msg; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/src/main/java/top/panll/assist/dto/AssistConstants.java b/src/main/java/top/panll/assist/dto/AssistConstants.java new file mode 100644 index 0000000..4847c23 --- /dev/null +++ b/src/main/java/top/panll/assist/dto/AssistConstants.java @@ -0,0 +1,8 @@ +package top.panll.assist.dto; + +public class AssistConstants { + + public final static String STREAM_CALL_INFO = "STREAM_CALL_INFO_"; + + public final static String MERGEORCUT = "MERGEORCUT_"; +} diff --git a/src/main/java/top/panll/assist/dto/MergeOrCutTaskInfo.java b/src/main/java/top/panll/assist/dto/MergeOrCutTaskInfo.java new file mode 100644 index 0000000..f7e174e --- /dev/null +++ b/src/main/java/top/panll/assist/dto/MergeOrCutTaskInfo.java @@ -0,0 +1,108 @@ +package top.panll.assist.dto; + + +public class MergeOrCutTaskInfo { + private String id; + private String createTime; + private String percentage; + + private String recordFile; + + private String downloadFile; + + private String playFile; + + private String app; + private String stream; + private String startTime; + private String endTime; + private String callId; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getPercentage() { + return percentage; + } + + public void setPercentage(String percentage) { + this.percentage = percentage; + } + + public String getRecordFile() { + return recordFile; + } + + public void setRecordFile(String recordFile) { + this.recordFile = recordFile; + } + + public String getDownloadFile() { + return downloadFile; + } + + public void setDownloadFile(String downloadFile) { + this.downloadFile = downloadFile; + } + + public String getPlayFile() { + return playFile; + } + + public void setPlayFile(String playFile) { + this.playFile = playFile; + } + + public String getCreateTime() { + return createTime; + } + + public void setCreateTime(String createTime) { + this.createTime = createTime; + } + + public String getApp() { + return app; + } + + public void setApp(String app) { + this.app = app; + } + + public String getStream() { + return stream; + } + + public void setStream(String stream) { + this.stream = stream; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public String getCallId() { + return callId; + } + + public void setCallId(String callId) { + this.callId = callId; + } +} diff --git a/src/main/java/top/panll/assist/dto/SignInfo.java b/src/main/java/top/panll/assist/dto/SignInfo.java new file mode 100644 index 0000000..471ed66 --- /dev/null +++ b/src/main/java/top/panll/assist/dto/SignInfo.java @@ -0,0 +1,31 @@ +package top.panll.assist.dto; + +public class SignInfo { + private String app; + private String stream; + private String type; + + public String getApp() { + return app; + } + + public void setApp(String app) { + this.app = app; + } + + public String getStream() { + return stream; + } + + public void setStream(String stream) { + this.stream = stream; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/src/main/java/top/panll/assist/dto/SpaceInfo.java b/src/main/java/top/panll/assist/dto/SpaceInfo.java new file mode 100644 index 0000000..a35591e --- /dev/null +++ b/src/main/java/top/panll/assist/dto/SpaceInfo.java @@ -0,0 +1,23 @@ +package top.panll.assist.dto; + +public class SpaceInfo { + private long total; + private long free; + + public long getTotal() { + return total; + } + + public void setTotal(long total) { + this.total = total; + } + + public long getFree() { + return free; + } + + public void setFree(long free) { + this.free = free; + } + +} diff --git a/src/main/java/top/panll/assist/dto/UserSettings.java b/src/main/java/top/panll/assist/dto/UserSettings.java new file mode 100644 index 0000000..520c782 --- /dev/null +++ b/src/main/java/top/panll/assist/dto/UserSettings.java @@ -0,0 +1,78 @@ +package top.panll.assist.dto; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * @author lin + */ +@Component +public class UserSettings { + + @Value("${userSettings.id}") + private String id; + + @Value("${userSettings.record-temp:./recordTemp}") + private String recordTempPath; + + @Value("${userSettings.record-temp-day:7}") + private int recordTempDay; + + @Value("${userSettings.ffmpeg}") + private String ffmpeg; + + @Value("${userSettings.ffprobe}") + private String ffprobe; + + @Value("${userSettings.threads:2}") + private int threads; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFfmpeg() { + return ffmpeg; + } + + public void setFfmpeg(String ffmpeg) { + this.ffmpeg = ffmpeg; + } + + public String getFfprobe() { + return ffprobe; + } + + public void setFfprobe(String ffprobe) { + this.ffprobe = ffprobe; + } + + + public int getRecordTempDay() { + return recordTempDay; + } + + public void setRecordTempDay(int recordTempDay) { + this.recordTempDay = recordTempDay; + } + + public int getThreads() { + return threads; + } + + public void setThreads(int threads) { + this.threads = threads; + } + + public String getRecordTempPath() { + return recordTempPath; + } + + public void setRecordTempPath(String recordTempPath) { + this.recordTempPath = recordTempPath; + } +} diff --git a/src/main/java/top/panll/assist/dto/VideoFile.java b/src/main/java/top/panll/assist/dto/VideoFile.java new file mode 100644 index 0000000..13d12e3 --- /dev/null +++ b/src/main/java/top/panll/assist/dto/VideoFile.java @@ -0,0 +1,77 @@ +package top.panll.assist.dto; + +import java.io.File; +import java.util.Date; + +/** + * 视频文件 + */ +public class VideoFile { + + /** + * 文件对象 + */ + private File file; + + /** + * 文件开始时间 + */ + private Date startTime; + + /** + * 文件结束时间 + */ + private Date endTime; + + + /** + * 时长, 单位:秒 + */ + private long duration; + + + /** + * 是否是目标格式 + */ + private boolean targetFormat; + + public File getFile() { + return file; + } + + public void setFile(File file) { + this.file = file; + } + + public Date getStartTime() { + return startTime; + } + + public void setStartTime(Date startTime) { + this.startTime = startTime; + } + + public Date getEndTime() { + return endTime; + } + + public void setEndTime(Date endTime) { + this.endTime = endTime; + } + + public long getDuration() { + return duration; + } + + public void setDuration(long duration) { + this.duration = duration; + } + + public boolean isTargetFormat() { + return targetFormat; + } + + public void setTargetFormat(boolean targetFormat) { + this.targetFormat = targetFormat; + } +} diff --git a/src/main/java/top/panll/assist/dto/VideoTaskInfo.java b/src/main/java/top/panll/assist/dto/VideoTaskInfo.java new file mode 100644 index 0000000..1959302 --- /dev/null +++ b/src/main/java/top/panll/assist/dto/VideoTaskInfo.java @@ -0,0 +1,79 @@ +package top.panll.assist.dto; + +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "视频合并任务的信息") +public class VideoTaskInfo { + + private String app; + private String stream; + private String startTime; + private String endTime; + private String callId; + + + @Schema(description = "视频文件路径列表") + private List filePathList; + + @Schema(description = "返回地址时的远程地址") + private String remoteHost; + + public List getFilePathList() { + return filePathList; + } + + public void setFilePathList(List filePathList) { + this.filePathList = filePathList; + } + + public String getRemoteHost() { + return remoteHost; + } + + public void setRemoteHost(String remoteHost) { + this.remoteHost = remoteHost; + } + + public String getApp() { + return app; + } + + public void setApp(String app) { + this.app = app; + } + + public String getStream() { + return stream; + } + + public void setStream(String stream) { + this.stream = stream; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public String getCallId() { + return callId; + } + + public void setCallId(String callId) { + this.callId = callId; + } +} diff --git a/src/main/java/top/panll/assist/service/FFmpegExecUtils.java b/src/main/java/top/panll/assist/service/FFmpegExecUtils.java new file mode 100644 index 0000000..407f71f --- /dev/null +++ b/src/main/java/top/panll/assist/service/FFmpegExecUtils.java @@ -0,0 +1,159 @@ +package top.panll.assist.service; + +import net.bramp.ffmpeg.FFmpeg; +import net.bramp.ffmpeg.FFmpegExecutor; +import net.bramp.ffmpeg.FFmpegUtils; +import net.bramp.ffmpeg.FFprobe; +import net.bramp.ffmpeg.builder.FFmpegBuilder; +import net.bramp.ffmpeg.job.FFmpegJob; +import net.bramp.ffmpeg.probe.FFmpegProbeResult; +import net.bramp.ffmpeg.progress.Progress; +import net.bramp.ffmpeg.progress.ProgressListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.util.DigestUtils; +import top.panll.assist.dto.UserSettings; +import top.panll.assist.dto.VideoFile; +import top.panll.assist.utils.RedisUtil; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.List; +import java.util.concurrent.TimeUnit; + +@Component +public class FFmpegExecUtils implements InitializingBean{ + + private final static Logger logger = LoggerFactory.getLogger(FFmpegExecUtils.class); +// private static FFmpegExecUtils instance; +// +// public FFmpegExecUtils() { +// } +// +// public static FFmpegExecUtils getInstance(){ +// if(instance==null){ +// synchronized (FFmpegExecUtils.class){ +// if(instance==null){ +// instance=new FFmpegExecUtils(); +// } +// } +// } +// return instance; +// } + @Autowired + private UserSettings userSettings; + + private FFprobe ffprobe; + private FFmpeg ffmpeg; + + public FFprobe getFfprobe() { + return ffprobe; + } + + public FFmpeg getFfmpeg() { + return ffmpeg; + } + + @Override + public void afterPropertiesSet() throws Exception { + String ffmpegPath = userSettings.getFfmpeg(); + String ffprobePath = userSettings.getFfprobe(); + this.ffmpeg = new FFmpeg(ffmpegPath); + this.ffprobe = new FFprobe(ffprobePath); + logger.info("wvp-pro辅助程序启动成功。 \n{}\n{} ", this.ffmpeg.version(), this.ffprobe.version()); + } + + + + public interface VideoHandEndCallBack { + void run(String status, double percentage, String result); + } + + @Async + public void mergeOrCutFile(List fils, File dest, String destFileName, VideoHandEndCallBack callBack){ + + if (fils == null || fils.size() == 0 || ffmpeg == null || ffprobe == null || dest== null || !dest.exists()){ + callBack.run("error", 0.0, null); + return; + } + + File tempFile = new File(dest.getAbsolutePath()); + if (!tempFile.exists()) { + tempFile.mkdirs(); + } + FFmpegExecutor executor = new FFmpegExecutor(ffmpeg, ffprobe); + String fileListName = tempFile.getAbsolutePath() + File.separator + "fileList"; + double durationAll = 0.0; + try { + BufferedWriter bw =new BufferedWriter(new FileWriter(fileListName)); + for (File file : fils) { + VideoFile videoFile = VideoFileFactory.createFile(this, file); + if (videoFile == null) { + return; + } + bw.write("file " + file.getAbsolutePath()); + bw.newLine(); + durationAll += videoFile.getDuration(); + } + bw.flush(); + bw.close(); + } catch (IOException e) { + e.printStackTrace(); + callBack.run("error", 0.0, null); + } + String recordFileResultPath = dest.getAbsolutePath() + File.separator + destFileName + ".mp4"; + long startTime = System.currentTimeMillis(); + FFmpegBuilder builder = new FFmpegBuilder() + + .setFormat("concat") + .overrideOutputFiles(true) + .setInput(fileListName) // Or filename + .addExtraArgs("-safe", "0") + .addExtraArgs("-threads", userSettings.getThreads() + "") + .addOutput(recordFileResultPath) + .setVideoCodec("copy") + .setAudioCodec("aac") + .setFormat("mp4") + .done(); + + double finalDurationAll = durationAll; + FFmpegJob job = executor.createJob(builder, (Progress progress) -> { + final double duration_ns = finalDurationAll * TimeUnit.SECONDS.toNanos(1); + double percentage = progress.out_time_ns / duration_ns; + +// Print out interesting information about the progress +// System.out.println(String.format( +// "[%.0f%%] status:%s frame:%d time:%s ms fps:%.0f speed:%.2fx", +// percentage * 100, +// progress.status, +// progress.frame, +// FFmpegUtils.toTimecode(progress.out_time_ns, TimeUnit.NANOSECONDS), +// progress.fps.doubleValue(), +// progress.speed +// )); + + if (progress.status.equals(Progress.Status.END)){ + callBack.run(progress.status.name(), percentage, recordFileResultPath); + }else { + callBack.run(progress.status.name(), percentage, null); + } + + }); + job.run(); + } + + public long duration(File file) throws IOException { + FFmpegProbeResult in = ffprobe.probe(file.getAbsolutePath()); + double duration = in.getFormat().duration * 1000; + long durationLong = new Double(duration).longValue(); + return durationLong; + } + +} diff --git a/src/main/java/top/panll/assist/service/FileManagerTimer.java b/src/main/java/top/panll/assist/service/FileManagerTimer.java new file mode 100644 index 0000000..8d1894f --- /dev/null +++ b/src/main/java/top/panll/assist/service/FileManagerTimer.java @@ -0,0 +1,86 @@ +package top.panll.assist.service; + +import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import top.panll.assist.dto.AssistConstants; +import top.panll.assist.dto.MergeOrCutTaskInfo; +import top.panll.assist.dto.UserSettings; +import top.panll.assist.utils.RedisUtil; + +import java.io.File; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +@Component +public class FileManagerTimer { + + private SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd"); + private final SimpleDateFormat simpleDateFormatForTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + private final static Logger logger = LoggerFactory.getLogger(FileManagerTimer.class); + + @Autowired + private UserSettings userSettings; + + @Autowired + private VideoFileService videoFileService; + + @Autowired + private RedisUtil redisUtil; + +// @Scheduled(fixedDelay = 2000) //测试 20秒执行一次 + @Scheduled(cron = "0 0 0 * * ?") //每天的0点执行 + public void execute(){ + if (userSettings.getRecordTempPath() == null) { + return; + } + + // 清理任务临时文件 + int recordTempDay = userSettings.getRecordTempDay(); + Date lastTempDate = new Date(); + Calendar lastTempCalendar = Calendar.getInstance(); + lastTempCalendar.setTime(lastTempDate); + lastTempCalendar.add(Calendar.DAY_OF_MONTH, -recordTempDay); + lastTempDate = lastTempCalendar.getTime(); + logger.info("[录像巡查]移除合并任务临时文件 {} 之前的文件", formatter.format(lastTempDate)); + File recordTempFile = new File(userSettings.getRecordTempPath()); + if (recordTempFile.exists() && recordTempFile.isDirectory() && recordTempFile.canWrite()) { + File[] tempFiles = recordTempFile.listFiles(); + if (tempFiles != null) { + for (File tempFile : tempFiles) { + if (tempFile.isFile() && tempFile.lastModified() < lastTempDate.getTime()) { + boolean result = FileUtils.deleteQuietly(tempFile); + if (result) { + logger.info("[录像巡查]成功移除合并任务临时文件 {} ", tempFile.getAbsolutePath()); + }else { + logger.info("[录像巡查]合并任务临时文件移除失败 {} ", tempFile.getAbsolutePath()); + } + } + } + } + } + // 清理redis记录 + String key = String.format("%S_%S_*", AssistConstants.MERGEORCUT, userSettings.getId()); + List taskKeys = redisUtil.scan(key); + for (Object taskKeyObj : taskKeys) { + String taskKey = (String) taskKeyObj; + MergeOrCutTaskInfo mergeOrCutTaskInfo = (MergeOrCutTaskInfo)redisUtil.get(taskKey); + try { + if (StringUtils.hasLength(mergeOrCutTaskInfo.getCreateTime()) + || simpleDateFormatForTime.parse(mergeOrCutTaskInfo.getCreateTime()).before(lastTempDate)) { + redisUtil.del(taskKey); + } + } catch (ParseException e) { + logger.error("[清理过期的redis合并任务信息] 失败", e); + } + } + } +} diff --git a/src/main/java/top/panll/assist/service/VideoFileFactory.java b/src/main/java/top/panll/assist/service/VideoFileFactory.java new file mode 100644 index 0000000..c3694bd --- /dev/null +++ b/src/main/java/top/panll/assist/service/VideoFileFactory.java @@ -0,0 +1,152 @@ +package top.panll.assist.service; + +import net.bramp.ffmpeg.probe.FFmpegProbeResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import top.panll.assist.dto.VideoFile; + +import java.io.File; +import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; + +public class VideoFileFactory { + + private final static Logger logger = LoggerFactory.getLogger(VideoFileFactory.class); + + public static VideoFile createFile(FFmpegExecUtils ffmpegExecUtils, File file){ + if (!file.exists()) { + return null; + } + if (!file.isFile()){ + return null; + } + if (!file.getName().endsWith(".mp4")){ + return null; + } + if (file.isHidden()){ + return null; + } + String date = file.getParentFile().getName(); + if (file.getName().indexOf(":") > 0) { + // 格式为 HH:mm:ss-HH:mm:ss-时长 + + String[] split = file.getName().split("-"); + if (split.length != 3) { + return null; + } + String startTimeStr = date + " " + split[0]; + String endTimeStr = date + " " + split[1]; + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + VideoFile videoFile = new VideoFile(); + videoFile.setFile(file); + videoFile.setTargetFormat(false); + try { + Date startTimeDate = simpleDateFormat.parse(startTimeStr); + videoFile.setStartTime(startTimeDate); + Date endTimeDate = simpleDateFormat.parse(endTimeStr); + videoFile.setEndTime(endTimeDate); + videoFile.setDuration((endTimeDate.getTime() - startTimeDate.getTime())); + } catch (ParseException e) { + logger.error("[构建视频文件对象] 格式化时间失败, file:{}", file.getAbsolutePath(), e); + return null; + } + return videoFile; + + }else if (getStrCountInStr(file.getName(), "-") == 3){ + + // 格式为zlm的录制格式 HH-mm-ss-序号 + String startStr = file.getName().substring(0, file.getName().lastIndexOf("-")); + String startTimeStr = date + " " + startStr; + VideoFile videoFile = null; + try { + FFmpegProbeResult fFmpegProbeResult = ffmpegExecUtils.getFfprobe().probe(file.getAbsolutePath()); + double duration = fFmpegProbeResult.getFormat().duration * 1000; + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss"); + Date startTimeDate = simpleDateFormat.parse(startTimeStr); + Date endTimeDate = new Date(startTimeDate.getTime() + new Double(duration).longValue()); + videoFile = new VideoFile(); + videoFile.setTargetFormat(false); + videoFile.setFile(file); + videoFile.setStartTime(startTimeDate); + videoFile.setEndTime(endTimeDate); + videoFile.setDuration((endTimeDate.getTime() - startTimeDate.getTime())/1000); + } catch (IOException e) { + logger.error("[构建视频文件对象] 获取视频时长失败, file:{}", file.getAbsolutePath(), e); + return null; + } catch (ParseException e) { + logger.error("[构建视频文件对象] 格式化时间失败, file:{}", file.getAbsolutePath(), e); + return null; + } + return videoFile; + }else if (getStrCountInStr(file.getName(), "-") == 2 && file.getName().length() == 10 ){ + // 格式为zlm的录制格式 HH-mm-ss + String startTimeStr = date + " " + file.getName(); + VideoFile videoFile = null; + try { + FFmpegProbeResult fFmpegProbeResult = ffmpegExecUtils.getFfprobe().probe(file.getAbsolutePath()); + double duration = fFmpegProbeResult.getFormat().duration * 1000; + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH-mm-ss"); + Date startTimeDate = simpleDateFormat.parse(startTimeStr); + Date endTimeDate = new Date(startTimeDate.getTime() + new Double(duration).longValue()); + videoFile = new VideoFile(); + videoFile.setTargetFormat(false); + videoFile.setFile(file); + videoFile.setStartTime(startTimeDate); + videoFile.setEndTime(endTimeDate); + videoFile.setDuration((endTimeDate.getTime() - startTimeDate.getTime())/1000); + } catch (IOException e) { + logger.error("[构建视频文件对象] 获取视频时长失败, file:{}", file.getAbsolutePath(), e); + return null; + } catch (ParseException e) { + logger.warn("[构建视频文件对象] 格式化时间失败, file:{}", file.getAbsolutePath(), e); + return null; + } + return videoFile; + }else if (getStrCountInStr(file.getName(), "-") == 1 ){ + // 格式为zlm的录制格式 HH-mm-ss + // 格式为 HH:mm:ss-HH:mm:ss-时长 + + String[] split = file.getName().split("-"); + if (split.length != 2) { + return null; + } + String startTimeStr = date + " " + split[0]; + String endTimeStr = date + " " + split[1]; + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HHmmss"); + VideoFile videoFile = new VideoFile(); + videoFile.setTargetFormat(true); + videoFile.setFile(file); + try { + Date startTimeDate = simpleDateFormat.parse(startTimeStr); + videoFile.setStartTime(startTimeDate); + Date endTimeDate = simpleDateFormat.parse(endTimeStr); + videoFile.setEndTime(endTimeDate); + videoFile.setDuration((endTimeDate.getTime() - startTimeDate.getTime())); + } catch (ParseException e) { + logger.error("[构建视频文件对象] 格式化时间失败, file:{}", file.getAbsolutePath(), e); + return null; + } + return videoFile; + }else { + return null; + } + } + + + public static int getStrCountInStr(String sourceStr, String content) { + int index = sourceStr.indexOf(content); + if (index < 0) { + return 0; + } + int count = 1; + int lastIndex = sourceStr.lastIndexOf(content); + while (index != lastIndex) { + index = sourceStr.indexOf(content, index + 1); + count++; + } + return count; + } + +} diff --git a/src/main/java/top/panll/assist/service/VideoFileService.java b/src/main/java/top/panll/assist/service/VideoFileService.java new file mode 100644 index 0000000..d0be785 --- /dev/null +++ b/src/main/java/top/panll/assist/service/VideoFileService.java @@ -0,0 +1,505 @@ +package top.panll.assist.service; + +import net.bramp.ffmpeg.FFprobe; +import net.bramp.ffmpeg.probe.FFmpegProbeResult; +import net.bramp.ffmpeg.progress.Progress; +import org.apache.commons.io.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.util.DigestUtils; +import org.springframework.util.ObjectUtils; +import top.panll.assist.controller.bean.ControllerException; +import top.panll.assist.controller.bean.ErrorCode; +import top.panll.assist.dto.*; +import top.panll.assist.utils.RedisUtil; +import top.panll.assist.utils.DateUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.*; + +@Service +public class VideoFileService { + + private final static Logger logger = LoggerFactory.getLogger(VideoFileService.class); + + @Autowired + private UserSettings userSettings; + + @Autowired + private RedisUtil redisUtil; + + @Autowired + private FFmpegExecUtils ffmpegExecUtils; + + + + private final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd"); + private final SimpleDateFormat simpleDateFormatForTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + public List getAppList(Boolean sort) { + File recordFile = new File(userSettings.getRecordTempPath()); + if (recordFile.isDirectory()) { + File[] files = recordFile.listFiles((File dir, String name) -> { + File currentFile = new File(dir.getAbsolutePath() + File.separator + name); + return currentFile.isDirectory() && !name.equals("recordTemp"); + }); + List result = Arrays.asList(files); + if (sort != null && sort) { + Collections.sort(result); + } + return result; + }else { + return null; + } + } + + public SpaceInfo getSpaceInfo(){ + File recordFile = new File(userSettings.getRecordTempPath()); + SpaceInfo spaceInfo = new SpaceInfo(); + spaceInfo.setFree(recordFile.getFreeSpace()); + spaceInfo.setTotal(recordFile.getTotalSpace()); + return spaceInfo; + } + + + public List getStreamList(File appFile, Boolean sort) { + if (appFile != null && appFile.isDirectory()) { + File[] files = appFile.listFiles((File dir, String name) -> { + File currentFile = new File(dir.getAbsolutePath() + File.separator + name); + return currentFile.isDirectory(); + }); + List result = Arrays.asList(files); + if (sort != null && sort) { + Collections.sort(result); + } + return result; + }else { + return null; + } + } + + /** + * 获取制定推流的指定时间段内的推流 + * @param app + * @param stream + * @param startTime + * @param endTime + * @return + */ + public List getFilesInTime(String app, String stream, Date startTime, Date endTime){ + + List result = new ArrayList<>(); + if (app == null || stream == null) { + return result; + } + + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HHmmss"); + SimpleDateFormat formatterForDate = new SimpleDateFormat("yyyy-MM-dd"); + String startTimeStr = null; + String endTimeStr = null; + if (startTime != null) { + startTimeStr = formatter.format(startTime); + } + if (endTime != null) { + endTimeStr = formatter.format(endTime); + } + + logger.debug("获取[app: {}, stream: {}, statime: {}, endTime: {}]的视频", app, stream, + startTimeStr, endTimeStr); + + File recordFile = new File(userSettings.getRecordTempPath()); + File streamFile = new File(recordFile.getAbsolutePath() + File.separator + app + File.separator + stream + File.separator); + if (!streamFile.exists()) { + logger.warn("获取[app: {}, stream: {}, statime: {}, endTime: {}]的视频时未找到目录: {}", app, stream, + startTimeStr, endTimeStr, stream); + return null; + } + + File[] dateFiles = streamFile.listFiles((File dir, String name) -> { + Date fileDate = null; + Date startDate = null; + Date endDate = null; + if (new File(dir + File.separator + name).isFile()) { + return false; + } + if (startTime != null) { + startDate = new Date(startTime.getTime() - ((startTime.getTime() + 28800000) % (86400000))); + } + if (endTime != null) { + endDate = new Date(endTime.getTime() - ((endTime.getTime() + 28800000) % (86400000))); + } + try { + fileDate = formatterForDate.parse(name); + } catch (ParseException e) { + logger.error("过滤日期文件时异常: {}-{}", name, e.getMessage()); + return false; + } + boolean filterResult = true; + + if (startDate != null) { + filterResult = filterResult && DateUtils.getStartOfDay(startDate).compareTo(fileDate) <= 0; + } + + if (endDate != null) { + filterResult = filterResult && DateUtils.getEndOfDay(endDate).compareTo(fileDate) >= 0; + } + + return filterResult ; + }); + + if (dateFiles != null && dateFiles.length > 0) { + for (File dateFile : dateFiles) { + File[] files = dateFile.listFiles((File dir, String name) ->{ + File currentFile = new File(dir + File.separator + name); + VideoFile videoFile = VideoFileFactory.createFile(ffmpegExecUtils, currentFile); + if (videoFile == null ) { + return false; + }else { + if (!videoFile.isTargetFormat()) { + return false; + } + if (startTime == null && endTime == null) { + return true; + }else if (startTime == null && endTime != null) { + return videoFile.getEndTime().before(endTime) + || videoFile.getEndTime().equals(endTime) + || (videoFile.getEndTime().after(endTime) && videoFile.getStartTime().before(endTime)); + }else if (startTime != null && endTime == null) { + return videoFile.getStartTime().after(startTime) + || videoFile.getStartTime().equals(startTime) + || (videoFile.getStartTime().before(startTime) && videoFile.getEndTime().after(startTime)); + }else { + return videoFile.getStartTime().after(startTime) + || videoFile.getStartTime().equals(startTime) + || (videoFile.getStartTime().before(startTime) && videoFile.getEndTime().after(startTime)) + || videoFile.getEndTime().before(endTime) + || videoFile.getEndTime().equals(endTime) + || (videoFile.getEndTime().after(endTime) && videoFile.getStartTime().before(endTime)); + } + } + }); + if (files != null && files.length > 0) { + result.addAll(Arrays.asList(files)); + } + } + } + if (!result.isEmpty()) { + result.sort((File f1, File f2) -> { + VideoFile videoFile1 = VideoFileFactory.createFile(ffmpegExecUtils, f1); + VideoFile videoFile2 = VideoFileFactory.createFile(ffmpegExecUtils, f2); + if (videoFile1 == null || !videoFile1.isTargetFormat() || videoFile2 == null || !videoFile2.isTargetFormat()) { + logger.warn("[根据时间获取视频文件] 排序错误,文件错误: {}/{}", f1.getName(), f2.getName()); + return 0; + } + return videoFile1.getStartTime().compareTo(videoFile2.getStartTime()); + }); + } + return result; + } + + + public String mergeOrCut(VideoTaskInfo videoTaskInfo) { + assert videoTaskInfo.getFilePathList() != null; + assert !videoTaskInfo.getFilePathList().isEmpty(); + String taskId = DigestUtils.md5DigestAsHex(String.valueOf(System.currentTimeMillis()).getBytes()); + String logInfo = String.format("app: %S, stream: %S, callId: %S, 任务ID:%S", + videoTaskInfo.getApp(), videoTaskInfo.getStream(), videoTaskInfo.getCallId(), taskId); + logger.info("[录像合并] 开始合并,{} ", logInfo); + List fileList = new ArrayList<>(); + for (String filePath : videoTaskInfo.getFilePathList()) { + File file = new File(filePath); + if (!file.exists()) { + logger.info("[录像合并] 失败,{} ", logInfo); + throw new ControllerException(ErrorCode.ERROR100.getCode(), filePath + "文件不存在"); + } + logger.info("[录像合并] 添加文件,{}, 文件: {}", logInfo, filePath); + fileList.add(file); + } + + File recordFile = new File(userSettings.getRecordTempPath() ); + if (!recordFile.exists()) { + if (!recordFile.mkdirs()) { + logger.info("[录像合并] 失败,{}, 创建临时目录失败", logInfo); + throw new ControllerException(ErrorCode.ERROR100.getCode(), "创建临时目录失败"); + } + } + MergeOrCutTaskInfo mergeOrCutTaskInfo = new MergeOrCutTaskInfo(); + mergeOrCutTaskInfo.setId(taskId); + mergeOrCutTaskInfo.setApp(videoTaskInfo.getApp()); + mergeOrCutTaskInfo.setStream(videoTaskInfo.getStream()); + mergeOrCutTaskInfo.setCallId(videoTaskInfo.getCallId()); + mergeOrCutTaskInfo.setStartTime(videoTaskInfo.getStartTime()); + mergeOrCutTaskInfo.setEndTime(videoTaskInfo.getEndTime()); + mergeOrCutTaskInfo.setCreateTime(simpleDateFormatForTime.format(System.currentTimeMillis())); + String destFileName = videoTaskInfo.getStream() + "_" + videoTaskInfo.getCallId(); + if (fileList.size() == 1) { + + // 文件只有一个则不合并,直接复制过去 + mergeOrCutTaskInfo.setPercentage("1"); + // 处理文件路径 + String recordFileResultPath = recordFile.getAbsolutePath() + File.separator + destFileName + ".mp4"; + File destFile = new File(recordFileResultPath); + destFile.deleteOnExit(); + try { + Files.copy(fileList.get(0).toPath(), Paths.get(recordFileResultPath)); + } catch (IOException e) { + logger.info("[录像合并] 失败, {}", logInfo, e); + throw new ControllerException(ErrorCode.ERROR100.getCode(), e.getMessage()); + } + mergeOrCutTaskInfo.setRecordFile("/download/" + destFileName + ".mp4"); + if (videoTaskInfo.getRemoteHost() != null) { + mergeOrCutTaskInfo.setDownloadFile(videoTaskInfo.getRemoteHost() + "/download.html?url=download/" + destFileName + ".mp4"); + mergeOrCutTaskInfo.setPlayFile(videoTaskInfo.getRemoteHost() + "/download/" + destFileName + ".mp4"); + } + String key = String.format("%S_%S_%S", AssistConstants.MERGEORCUT , userSettings.getId(), mergeOrCutTaskInfo.getId()); + redisUtil.set(key, mergeOrCutTaskInfo); + logger.info("[录像合并] 成功, 任务ID:{}", taskId); + }else { + ffmpegExecUtils.mergeOrCutFile(fileList, recordFile, destFileName, (status, percentage, result)->{ + // 发出redis通知 + if (status.equals(Progress.Status.END.name())) { + mergeOrCutTaskInfo.setPercentage("1"); + + // 处理文件路径 + String relativize = new File(result).getName(); + mergeOrCutTaskInfo.setRecordFile(relativize.toString()); + if (videoTaskInfo.getRemoteHost() != null) { + mergeOrCutTaskInfo.setDownloadFile(videoTaskInfo.getRemoteHost() + "/download.html?url=download/" + relativize); + mergeOrCutTaskInfo.setPlayFile(videoTaskInfo.getRemoteHost() + "/download/" + relativize); + } + logger.info("[录像合并] 成功, {}", logInfo); + }else { + mergeOrCutTaskInfo.setPercentage(percentage + ""); + } + String key = String.format("%S_%S_%S", AssistConstants.MERGEORCUT, userSettings.getId(), mergeOrCutTaskInfo.getId()); + redisUtil.set(key, mergeOrCutTaskInfo); + }); + } + + return taskId; + } + + public List getDateList(File streamFile, Integer year, Integer month, Boolean sort) { + if (!streamFile.exists() && streamFile.isDirectory()) { + logger.warn("获取[]的视频时未找到目录: {}",streamFile.getName()); + return null; + } + File[] dateFiles = streamFile.listFiles((File dir, String name)->{ + File currentFile = new File(dir.getAbsolutePath() + File.separator + name); + if (!currentFile.isDirectory()){ + return false; + } + Date date = null; + try { + date = simpleDateFormat.parse(name); + } catch (ParseException e) { + logger.error("格式化时间{}错误", name); + return false; + } + Calendar c = Calendar.getInstance(); + c.setTime(date); + int y = c.get(Calendar.YEAR); + int m = c.get(Calendar.MONTH) + 1; + if (year != null) { + if (month != null) { + return y == year && m == month; + }else { + return y == year; + } + }else { + return true; + } + + }); + if (dateFiles == null) { + return new ArrayList<>(); + } + List dateFileList = Arrays.asList(dateFiles); + if (sort != null && sort) { + dateFileList.sort((File f1, File f2)->{ + int sortResult = 0; + + try { + sortResult = simpleDateFormat.parse(f1.getName()).compareTo(simpleDateFormat.parse(f2.getName())); + } catch (ParseException e) { + logger.error("格式化时间{}/{}错误", f1.getName(), f2.getName()); + } + return sortResult; + }); + } + + return dateFileList; + } + + public List getTaskListForDownload(String app, String stream, String callId, Boolean isEnd, String taskId) { + logger.info("[查询录像合成列表] app: {}, stream: {}, callId: {}, isEnd: {}, taskId: {}", + app, stream, callId, isEnd, taskId); + ArrayList result = new ArrayList<>(); + if (taskId == null) { + taskId = "*"; + } + List taskCatch = redisUtil.scan(String.format("%S_%S_%S", AssistConstants.MERGEORCUT, + userSettings.getId(), taskId)); + for (int i = 0; i < taskCatch.size(); i++) { + String keyItem = taskCatch.get(i).toString(); + MergeOrCutTaskInfo mergeOrCutTaskInfo = (MergeOrCutTaskInfo)redisUtil.get(keyItem); + if (mergeOrCutTaskInfo != null){ + if ((!ObjectUtils.isEmpty(app) && !mergeOrCutTaskInfo.getApp().equals(app)) + || (!ObjectUtils.isEmpty(stream) && !mergeOrCutTaskInfo.getStream().equals(stream)) + || (!ObjectUtils.isEmpty(callId) && !mergeOrCutTaskInfo.getCallId().equals(callId)) + ) { + continue; + } + if (mergeOrCutTaskInfo.getPercentage() != null){ + if (isEnd != null ) { + if (isEnd) { + if (Double.parseDouble(mergeOrCutTaskInfo.getPercentage()) == 1){ + result.add(mergeOrCutTaskInfo); + } + }else { + if (Double.parseDouble(mergeOrCutTaskInfo.getPercentage()) < 1){ + result.add((MergeOrCutTaskInfo)redisUtil.get(keyItem)); + } + } + }else { + result.add((MergeOrCutTaskInfo)redisUtil.get(keyItem)); + } + } + } + } + result.sort((MergeOrCutTaskInfo m1, MergeOrCutTaskInfo m2)->{ + int sortResult = 0; + try { + sortResult = simpleDateFormatForTime.parse(m1.getCreateTime()).compareTo(simpleDateFormatForTime.parse(m2.getCreateTime())); + if (sortResult == 0) { + sortResult = simpleDateFormatForTime.parse(m1.getCreateTime()).compareTo(simpleDateFormatForTime.parse(m2.getCreateTime())); + } + if (sortResult == 0) { + sortResult = simpleDateFormatForTime.parse(m1.getCreateTime()).compareTo(simpleDateFormatForTime.parse(m2.getCreateTime())); + } + } catch (ParseException e) { + e.printStackTrace(); + } + return sortResult * -1; + }); + + return result; + } + + public boolean collection(String app, String stream, String type) { + File streamFile = new File(userSettings.getRecordTempPath() + File.separator + app + File.separator + stream); + boolean result = false; + if (streamFile.exists() && streamFile.isDirectory() && streamFile.canWrite()) { + File signFile = new File(streamFile.getAbsolutePath() + File.separator + type + ".sign"); + try { + result = signFile.createNewFile(); + } catch (IOException e) { + logger.error("[收藏文件]失败,{}/{}", app, stream); + } + } + return result; + } + + public boolean removeCollection(String app, String stream, String type) { + File signFile = new File(userSettings.getRecordTempPath() + File.separator + app + File.separator + stream + File.separator + type + ".sign"); + boolean result = false; + if (signFile.exists() && signFile.isFile()) { + result = signFile.delete(); + } + return result; + } + + public List getCollectionList(String app, String stream, String type) { + List appList = this.getAppList(true); + List result = new ArrayList<>(); + if (appList.size() > 0) { + for (File appFile : appList) { + if (app != null) { + if (!app.equals(appFile.getName())) { + continue; + } + } + List streamList = getStreamList(appFile, true); + if (streamList.size() > 0) { + for (File streamFile : streamList) { + if (stream != null) { + if (!stream.equals(streamFile.getName())) { + continue; + } + } + + if (type != null) { + File signFile = new File(streamFile.getAbsolutePath() + File.separator + type + ".sign"); + if (signFile.exists()) { + SignInfo signInfo = new SignInfo(); + signInfo.setApp(appFile.getName()); + signInfo.setStream(streamFile.getName()); + signInfo.setType(type); + result.add(signInfo); + } + }else { + streamFile.listFiles((File dir, String name) -> { + File currentFile = new File(dir.getAbsolutePath() + File.separator + name); + if (currentFile.isFile() && name.endsWith(".sign")){ + String currentType = name.substring(0, name.length() - ".sign".length()); + SignInfo signInfo = new SignInfo(); + signInfo.setApp(appFile.getName()); + signInfo.setStream(streamFile.getName()); + signInfo.setType(currentType); + result.add(signInfo); + } + return false; + }); + } + } + } + } + } + return result; + } + + public long fileDuration(String app, String stream) { + List allFiles = getFilesInTime(app, stream, null, null); + long durationResult = 0; + if (allFiles != null && allFiles.size() > 0) { + for (File file : allFiles) { + try { + durationResult += ffmpegExecUtils.duration(file); + } catch (IOException e) { + logger.error("获取{}视频时长错误:{}", file.getAbsolutePath(), e.getMessage()); + } + } + } + return durationResult; + } + + public int deleteFile(List filePathList) { + assert filePathList != null; + assert filePathList.isEmpty(); + int deleteResult = 0; + for (String filePath : filePathList) { + File file = new File(filePath); + if (file.exists()) { + if (file.delete()) { + deleteResult ++; + } + }else { + logger.warn("[删除文件] 文件不存在,{}", filePath); + } + } + if (deleteResult == 0) { + throw new ControllerException(ErrorCode.ERROR100.getCode(), "未删除任何文件"); + } + return deleteResult; + } +} diff --git a/src/main/java/top/panll/assist/utils/DateUtils.java b/src/main/java/top/panll/assist/utils/DateUtils.java new file mode 100644 index 0000000..becee36 --- /dev/null +++ b/src/main/java/top/panll/assist/utils/DateUtils.java @@ -0,0 +1,46 @@ +package top.panll.assist.utils; + +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.Locale; + +public class DateUtils { + + public static final String PATTERNForDateTime = "yyyy-MM-dd HH:mm:ss"; + + public static final String PATTERNForDate = "yyyy-MM-dd"; + + public static final String zoneStr = "Asia/Shanghai"; + + + + // 获得某天最大时间 2020-02-19 23:59:59 + public static Date getEndOfDay(Date date) { + LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault());; + LocalDateTime endOfDay = localDateTime.with(LocalTime.MAX); + return Date.from(endOfDay.atZone(ZoneId.systemDefault()).toInstant()); + } + + // 获得某天最小时间 2020-02-17 00:00:00 + public static Date getStartOfDay(Date date) { + LocalDateTime localDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(date.getTime()), ZoneId.systemDefault()); + LocalDateTime startOfDay = localDateTime.with(LocalTime.MIN); + return Date.from(startOfDay.atZone(ZoneId.systemDefault()).toInstant()); + } + + public static String getDateStr(Date date) { + SimpleDateFormat formatter = new SimpleDateFormat(PATTERNForDate); + return formatter.format(date); + } + + public static String getDateTimeStr(Date date) { + SimpleDateFormat formatter = new SimpleDateFormat(PATTERNForDateTime); + return formatter.format(date); + } + +} diff --git a/src/main/java/top/panll/assist/utils/RedisUtil.java b/src/main/java/top/panll/assist/utils/RedisUtil.java new file mode 100644 index 0000000..95097bb --- /dev/null +++ b/src/main/java/top/panll/assist/utils/RedisUtil.java @@ -0,0 +1,723 @@ +package top.panll.assist.utils; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.*; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * @Description:Redis工具类 + * @author: swwheihei + * @date: 2020年5月6日 下午8:27:29 + */ +@Component +@SuppressWarnings(value = {"rawtypes", "unchecked"}) +public class RedisUtil { + + @Autowired + private RedisTemplate redisTemplate; + + /** + * 指定缓存失效时间 + * @param key 键 + * @param time 时间(秒) + * @return true / false + */ + public boolean expire(String key, long time) { + try { + if (time > 0) { + redisTemplate.expire(key, time, TimeUnit.SECONDS); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + + } + + /** + * 根据 key 获取过期时间 + * @param key 键 + * @return + */ + public long getExpire(String key) { + return redisTemplate.getExpire(key, TimeUnit.SECONDS); + } + + /** + * 判断 key 是否存在 + * @param key 键 + * @return true / false + */ + public boolean hasKey(String key) { + try { + return redisTemplate.hasKey(key); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 删除缓存 + * @SuppressWarnings("unchecked") 忽略类型转换警告 + * @param key 键(一个或者多个) + */ + public boolean del(String... key) { + try { + if (key != null && key.length > 0) { + if (key.length == 1) { + redisTemplate.delete(key[0]); + } else { +// 传入一个 Collection 集合 + redisTemplate.delete(CollectionUtils.arrayToList(key)); + } + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + +// ============================== String ============================== + + /** + * 普通缓存获取 + * @param key 键 + * @return 值 + */ + public Object get(String key) { + return key == null ? null : redisTemplate.opsForValue().get(key); + } + + /** + * 普通缓存放入 + * @param key 键 + * @param value 值 + * @return true / false + */ + public boolean set(String key, Object value) { + try { + redisTemplate.opsForValue().set(key, value); + return true; + } catch (Exception e) { +// e.printStackTrace(); + return false; + } + } + + /** + * 普通缓存放入并设置时间 + * @param key 键 + * @param value 值 + * @param time 时间(秒),如果 time < 0 则设置无限时间 + * @return true / false + */ + public boolean set(String key, Object value, long time) { + try { + if (time > 0) { + redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS); + } else { + set(key, value); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 递增 + * @param key 键 + * @param delta 递增大小 + * @return + */ + public long incr(String key, long delta) { + if (delta < 0) { + throw new RuntimeException("递增因子必须大于 0"); + } + return redisTemplate.opsForValue().increment(key, delta); + } + + /** + * 递减 + * @param key 键 + * @param delta 递减大小 + * @return + */ + public long decr(String key, long delta) { + if (delta < 0) { + throw new RuntimeException("递减因子必须大于 0"); + } + return redisTemplate.opsForValue().increment(key, delta); + } + +// ============================== Map ============================== + + /** + * HashGet + * @param key 键(no null) + * @param item 项(no null) + * @return 值 + */ + public Object hget(String key, String item) { + return redisTemplate.opsForHash().get(key, item); + } + + /** + * 获取 key 对应的 map + * @param key 键(no null) + * @return 对应的多个键值 + */ + public Map hmget(String key) { + return redisTemplate.opsForHash().entries(key); + } + + /** + * HashSet + * @param key 键 + * @param map 值 + * @return true / false + */ + public boolean hmset(String key, Map map) { + try { + redisTemplate.opsForHash().putAll(key, map); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * HashSet 并设置时间 + * @param key 键 + * @param map 值 + * @param time 时间 + * @return true / false + */ + public boolean hmset(String key, Map map, long time) { + try { + redisTemplate.opsForHash().putAll(key, map); + if (time > 0) { + expire(key, time); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 向一张 Hash表 中放入数据,如不存在则创建 + * @param key 键 + * @param item 项 + * @param value 值 + * @return true / false + */ + public boolean hset(String key, String item, Object value) { + try { + redisTemplate.opsForHash().put(key, item, value); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 向一张 Hash表 中放入数据,并设置时间,如不存在则创建 + * @param key 键 + * @param item 项 + * @param value 值 + * @param time 时间(如果原来的 Hash表 设置了时间,这里会覆盖) + * @return true / false + */ + public boolean hset(String key, String item, Object value, long time) { + try { + redisTemplate.opsForHash().put(key, item, value); + if (time > 0) { + expire(key, time); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 删除 Hash表 中的值 + * @param key 键 + * @param item 项(可以多个,no null) + */ + public void hdel(String key, Object... item) { + redisTemplate.opsForHash().delete(key, item); + } + + /** + * 判断 Hash表 中是否有该键的值 + * @param key 键(no null) + * @param item 值(no null) + * @return true / false + */ + public boolean hHasKey(String key, String item) { + return redisTemplate.opsForHash().hasKey(key, item); + } + + /** + * Hash递增,如果不存在则创建一个,并把新增的值返回 + * @param key 键 + * @param item 项 + * @param by 递增大小 > 0 + * @return + */ + public Double hincr(String key, String item, Double by) { + return redisTemplate.opsForHash().increment(key, item, by); + } + + /** + * Hash递减 + * @param key 键 + * @param item 项 + * @param by 递减大小 + * @return + */ + public Double hdecr(String key, String item, Double by) { + return redisTemplate.opsForHash().increment(key, item, -by); + } + +// ============================== Set ============================== + + /** + * 根据 key 获取 set 中的所有值 + * @param key 键 + * @return 值 + */ + public Set sGet(String key) { + try { + return redisTemplate.opsForSet().members(key); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + /** + * 从键为 key 的 set 中,根据 value 查询是否存在 + * @param key 键 + * @param value 值 + * @return true / false + */ + public boolean sHasKey(String key, Object value) { + try { + return redisTemplate.opsForSet().isMember(key, value); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 将数据放入 set缓存 + * @param key 键值 + * @param values 值(可以多个) + * @return 成功个数 + */ + public long sSet(String key, Object... values) { + try { + return redisTemplate.opsForSet().add(key, values); + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + /** + * 将数据放入 set缓存,并设置时间 + * @param key 键 + * @param time 时间 + * @param values 值(可以多个) + * @return 成功放入个数 + */ + public long sSet(String key, long time, Object... values) { + try { + long count = redisTemplate.opsForSet().add(key, values); + if (time > 0) { + expire(key, time); + } + return count; + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + /** + * 获取 set缓存的长度 + * @param key 键 + * @return 长度 + */ + public long sGetSetSize(String key) { + try { + return redisTemplate.opsForSet().size(key); + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + /** + * 移除 set缓存中,值为 value 的 + * @param key 键 + * @param values 值 + * @return 成功移除个数 + */ + public long setRemove(String key, Object... values) { + try { + return redisTemplate.opsForSet().remove(key, values); + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } +// ============================== ZSet ============================== + + /** + * 添加一个元素, zset与set最大的区别就是每个元素都有一个score,因此有个排序的辅助功能; zadd + * + * @param key + * @param value + * @param score + */ + public void zAdd(Object key, Object value, double score) { + redisTemplate.opsForZSet().add(key, value, score); + } + + /** + * 删除元素 zrem + * + * @param key + * @param value + */ + public void zRemove(Object key, Object value) { + redisTemplate.opsForZSet().remove(key, value); + } + + /** + * score的增加or减少 zincrby + * + * @param key + * @param value + * @param score + */ + public Double zIncrScore(Object key, Object value, double score) { + return redisTemplate.opsForZSet().incrementScore(key, value, score); + } + + /** + * 查询value对应的score zscore + * + * @param key + * @param value + * @return + */ + public Double zScore(Object key, Object value) { + return redisTemplate.opsForZSet().score(key, value); + } + + /** + * 判断value在zset中的排名 zrank + * + * @param key + * @param value + * @return + */ + public Long zRank(Object key, Object value) { + return redisTemplate.opsForZSet().rank(key, value); + } + + /** + * 返回集合的长度 + * + * @param key + * @return + */ + public Long zSize(Object key) { + return redisTemplate.opsForZSet().zCard(key); + } + + /** + * 查询集合中指定顺序的值, 0 -1 表示获取全部的集合内容 zrange + * + * 返回有序的集合,score小的在前面 + * + * @param key + * @param start + * @param end + * @return + */ + public Set ZRange(Object key, int start, int end) { + return redisTemplate.opsForZSet().range(key, start, end); + } + /** + * 查询集合中指定顺序的值和score,0, -1 表示获取全部的集合内容 + * + * @param key + * @param start + * @param end + * @return + */ + public Set> zRangeWithScore(Object key, int start, int end) { + return redisTemplate.opsForZSet().rangeWithScores(key, start, end); + } + /** + * 查询集合中指定顺序的值 zrevrange + * + * 返回有序的集合中,score大的在前面 + * + * @param key + * @param start + * @param end + * @return + */ + public Set zRevRange(Object key, int start, int end) { + return redisTemplate.opsForZSet().reverseRange(key, start, end); + } + /** + * 根据score的值,来获取满足条件的集合 zrangebyscore + * + * @param key + * @param min + * @param max + * @return + */ + public Set zSortRange(Object key, int min, int max) { + return redisTemplate.opsForZSet().rangeByScore(key, min, max); + } + + +// ============================== List ============================== + + /** + * 获取 list缓存的内容 + * @param key 键 + * @param start 开始 + * @param end 结束(0 到 -1 代表所有值) + * @return + */ + public List lGet(String key, long start, long end) { + try { + return redisTemplate.opsForList().range(key, start, end); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + /** + * 获取 list缓存的长度 + * @param key 键 + * @return 长度 + */ + public long lGetListSize(String key) { + try { + return redisTemplate.opsForList().size(key); + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + /** + * 根据索引 index 获取键为 key 的 list 中的元素 + * @param key 键 + * @param index 索引 + * 当 index >= 0 时 {0:表头, 1:第二个元素} + * 当 index < 0 时 {-1:表尾, -2:倒数第二个元素} + * @return 值 + */ + public Object lGetIndex(String key, long index) { + try { + return redisTemplate.opsForList().index(key, index); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + /** + * 将值 value 插入键为 key 的 list 中,如果 list 不存在则创建空 list + * @param key 键 + * @param value 值 + * @return true / false + */ + public boolean lSet(String key, Object value) { + try { + redisTemplate.opsForList().rightPush(key, value); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 将值 value 插入键为 key 的 list 中,并设置时间 + * @param key 键 + * @param value 值 + * @param time 时间 + * @return true / false + */ + public boolean lSet(String key, Object value, long time) { + try { + redisTemplate.opsForList().rightPush(key, value); + if (time > 0) { + expire(key, time); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 将 values 插入键为 key 的 list 中 + * @param key 键 + * @param values 值 + * @return true / false + */ + public boolean lSetList(String key, List values) { + try { + redisTemplate.opsForList().rightPushAll(key, values); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 将 values 插入键为 key 的 list 中,并设置时间 + * @param key 键 + * @param values 值 + * @param time 时间 + * @return true / false + */ + public boolean lSetList(String key, List values, long time) { + try { + redisTemplate.opsForList().rightPushAll(key, values); + if (time > 0) { + expire(key, time); + } + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 根据索引 index 修改键为 key 的值 + * @param key 键 + * @param index 索引 + * @param value 值 + * @return true / false + */ + public boolean lUpdateIndex(String key, long index, Object value) { + try { + redisTemplate.opsForList().set(key, index, value); + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 在键为 key 的 list 中删除值为 value 的元素 + * @param key 键 + * @param count 如果 count == 0 则删除 list 中所有值为 value 的元素 + * 如果 count > 0 则删除 list 中最左边那个值为 value 的元素 + * 如果 count < 0 则删除 list 中最右边那个值为 value 的元素 + * @param value + * @return + */ + public long lRemove(String key, long count, Object value) { + try { + return redisTemplate.opsForList().remove(key, count, value); + } catch (Exception e) { + e.printStackTrace(); + return 0; + } + } + + /** + * 模糊查询 + * @param key 键 + * @return true / false + */ + public List keys(String key) { + try { + Set set = redisTemplate.keys(key); + return new ArrayList<>(set); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + + /** + * 模糊查询 + * @param query 查询参数 + * @return + */ +// public List scan(String query) { +// List result = new ArrayList<>(); +// try { +// Cursor> cursor = redisTemplate.opsForHash().scan("field", +// ScanOptions.scanOptions().match(query).count(1000).build()); +// while (cursor.hasNext()) { +// Map.Entry entry = cursor.next(); +// result.add(entry.getKey()); +// Object key = entry.getKey(); +// Object valueSet = entry.getValue(); +// } +// //关闭cursor +// cursor.close(); +// } catch (Exception e) { +// e.printStackTrace(); +// } +// return result; +// } + + /** + * 模糊查询 + * @param query 查询参数 + * @return + */ + public List scan(String query) { + Set resultKeys = redisTemplate.execute((RedisCallback>) connection -> { + ScanOptions scanOptions = ScanOptions.scanOptions().match("*" + query + "*").count(1000).build(); + Cursor scan = connection.scan(scanOptions); + Set keys = new HashSet<>(); + while (scan.hasNext()) { + byte[] next = scan.next(); + keys.add(new String(next)); + } + return keys; + }); + + return new ArrayList<>(resultKeys); + } + +} diff --git a/src/main/resources/all-application.yml b/src/main/resources/all-application.yml new file mode 100644 index 0000000..ae1a6fd --- /dev/null +++ b/src/main/resources/all-application.yml @@ -0,0 +1,57 @@ +spring: + # REDIS数据库配置 + redis: + # [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1 + host: 127.0.0.1 + # [必须修改] 端口号 + port: 6379 + # [可选] 数据库 DB + database: 8 + # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接 + password: + # [可选] 超时时间 + timeout: 10000 + +# [必选] WVP监听的HTTP端口, 网页和接口调用都是这个端口 +server: + port: 18081 + # [可选] HTTPS配置, 默认不开启 + ssl: + # [可选] 是否开启HTTPS访问 + enabled: false + # [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名 + key-store: classpath:xxx.jks + # [可选] 证书密码 + key-password: password + # [可选] 证书类型, 默认为jks,根据实际修改 + key-store-type: JKS + +# [根据业务需求配置] +user-settings: + # [可选 ] 临时录像路径 + record-temp-path: ./recordTemp + # [可选 ] 录像保存时长(单位: 天)每天晚12点自动对过期文件执行清理, 不配置则不删除 + record-temp-day: 7 + # [可选 ] 录像下载合成临时文件保存时长, 不配置默认取值recordDay(单位: 天)每天晚12点自动对过期文件执行清理 + # recordTempDay: 7 + # [必选 ] ffmpeg路径 + ffmpeg: /usr/bin/ffmpeg + # [必选 ] ffprobe路径, 一般安装ffmpeg就会自带, 一般跟ffmpeg在同一目录,用于查询文件的信息 + ffprobe: /usr/bin/ffprobe + # [可选 ] 限制 ffmpeg 合并文件使用的线程数,间接限制cpu使用率, 默认2 限制到50% + threads: 2 + +swagger-ui: + +# [可选] 日志配置, 一般不需要改 +logging: + file: + name: logs/wvp.log + max-history: 30 + max-size: 10MB + total-size-cap: 300MB + level: + root: WARN + top: + panll: + assist: info \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..01448bb --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,57 @@ +spring: + # REDIS数据库配置 + redis: + # [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1 + host: 127.0.0.1 + # [必须修改] 端口号 + port: 6379 + # [可选] 数据库 DB + database: 8 + # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接 + password: + # [可选] 超时时间 + timeout: 10000 + +# [可选] WVP监听的HTTP端口, 网页和接口调用都是这个端口 +server: + port: 18081 + # [可选] HTTPS配置, 默认不开启 + ssl: + # [可选] 是否开启HTTPS访问 + enabled: false + # [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名 + key-store: classpath:xxx.jks + # [可选] 证书密码 + key-password: password + # [可选] 证书类型, 默认为jks,根据实际修改 + key-store-type: JKS + +# [根据业务需求配置] +userSettings: + # [可选 ] zlm配置的录像路径, + record: /media/lin/Server/ZLMediaKit/dev/ZLMediaKit/release/linux/Debug/www/record + # [可选 ] 录像保存时长(单位: 天)每天晚12点自动对过期文件执行清理 + recordDay: 7 + # [可选 ] 录像下载合成临时文件保存时长, 不配置默认取值recordDay(单位: 天)每天晚12点自动对过期文件执行清理 + # recordTempDay: 7 + # [必选 ] ffmpeg路径 + ffmpeg: /usr/bin/ffmpeg + # [必选 ] ffprobe路径, 一般安装ffmpeg就会自带, 一般跟ffmpeg在同一目录,用于查询文件的信息 + ffprobe: /usr/bin/ffprobe + # [可选 ] 限制 ffmpeg 合并文件使用的线程数,间接限制cpu使用率, 默认2 限制到50% + threads: 2 + +swagger-ui: + +# [可选] 日志配置, 一般不需要改 +logging: + file: + name: logs/wvp.log + max-history: 30 + max-size: 10MB + total-size-cap: 300MB + level: + root: WARN + top: + panll: + assist: info \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..fb31bb0 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,57 @@ +spring: + # REDIS数据库配置 + redis: + # [可选] 超时时间 + timeout: 10000 + # 以下为单机配置 + # [必须修改] Redis服务器IP, REDIS安装在本机的,使用127.0.0.1 + host: 127.0.0.1 + # [必须修改] 端口号 + port: 6379 + # [可选] 数据库 DB + database: 1 + # [可选] 访问密码,若你的redis服务器没有设置密码,就不需要用密码去连接 + password: + # 以下为集群配置 +# cluster: +# nodes: 192.168.1.242:7001 +# password: 4767cb971b40a1300fa09b7f87b09d1c + +# [可选] WVP监听的HTTP端口, 网页和接口调用都是这个端口 +server: + port: 18089 + # [可选] HTTPS配置, 默认不开启 + ssl: + # [可选] 是否开启HTTPS访问 + enabled: false + # [可选] 证书文件路径,放置在resource/目录下即可,修改xxx为文件名 + key-store: classpath:xxx.jks + # [可选] 证书密码 + key-password: password + # [可选] 证书类型, 默认为jks,根据实际修改 + key-store-type: JKS + +# [根据业务需求配置] +userSettings: + id: 111 + # [可选 ] 录像保存时长(单位: 天)每天晚12点自动对过期文件执行清理, 不配置则不删除 + record-temp-day: 7 + # [必选 ] ffmpeg路径 + ffmpeg: ./lib/ffmpeg + # [必选 ] ffprobe路径, 一般安装ffmpeg就会自带, 一般跟ffmpeg在同一目录,用于查询文件的信息, + ffprobe: ./lib/ffprobe + # [可选 ] 限制 ffmpeg 合并文件使用的线程数,间接限制cpu使用率, 默认2 限制到50% + threads: 2 + +# [可选] 日志配置, 一般不需要改 +logging: + file: + name: logs/wvp.log + max-history: 30 + max-size: 10MB + total-size-cap: 300MB + level: + root: WARN + top: + panll: + assist: info \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d74c444 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + active: local diff --git a/src/main/resources/static/download.html b/src/main/resources/static/download.html new file mode 100644 index 0000000..d370fa0 --- /dev/null +++ b/src/main/resources/static/download.html @@ -0,0 +1,25 @@ + + + + + + + 下载 + + + + + + \ No newline at end of file diff --git a/src/test/java/top/panll/assist/WvpProAssistApplicationTests.java b/src/test/java/top/panll/assist/WvpProAssistApplicationTests.java new file mode 100644 index 0000000..0888a8d --- /dev/null +++ b/src/test/java/top/panll/assist/WvpProAssistApplicationTests.java @@ -0,0 +1,13 @@ +package top.panll.assist; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class WvpProAssistApplicationTests { + + @Test + void contextLoads() { + } + +}