视频
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package com.hotwj.platform.video.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 视频分片上传与转码配置
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "video.upload")
|
||||
public class VideoUploadProperties {
|
||||
|
||||
/**
|
||||
* 工作目录,包含分片、源文件和 HLS 输出目录。
|
||||
*/
|
||||
private String workDir = System.getProperty("java.io.tmpdir") + File.separator + "hot-video-upload";
|
||||
|
||||
/**
|
||||
* 对外访问的 HLS 路径前缀。
|
||||
*/
|
||||
private String publicUrlPrefix = "/video-hls";
|
||||
|
||||
/**
|
||||
* 对外访问的基础域名,可为空。
|
||||
*/
|
||||
private String publicBaseUrl;
|
||||
|
||||
/**
|
||||
* FFmpeg 可执行命令。
|
||||
*/
|
||||
private String ffmpegCommand = "ffmpeg";
|
||||
|
||||
/**
|
||||
* 分片状态保留时长(小时)。
|
||||
*/
|
||||
private Integer chunkExpireHours = 24;
|
||||
|
||||
/**
|
||||
* 任务状态保留时长(小时)。
|
||||
*/
|
||||
private Integer taskExpireHours = 24;
|
||||
|
||||
/**
|
||||
* HLS 切片时长(秒)。
|
||||
*/
|
||||
private Integer hlsSegmentSeconds = 10;
|
||||
|
||||
public Duration chunkExpireDuration() {
|
||||
return Duration.ofHours(chunkExpireHours == null ? 24L : chunkExpireHours.longValue());
|
||||
}
|
||||
|
||||
public Duration taskExpireDuration() {
|
||||
return Duration.ofHours(taskExpireHours == null ? 24L : taskExpireHours.longValue());
|
||||
}
|
||||
|
||||
public String normalizedPublicUrlPrefix() {
|
||||
if (publicUrlPrefix == null || publicUrlPrefix.isBlank()) {
|
||||
return "/video-hls";
|
||||
}
|
||||
String value = publicUrlPrefix.startsWith("/") ? publicUrlPrefix : "/" + publicUrlPrefix;
|
||||
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.hotwj.platform.video.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import com.hotwj.platform.video.domain.bo.VideoChunkUploadBo;
|
||||
import com.hotwj.platform.video.domain.bo.VideoMergeBo;
|
||||
import com.hotwj.platform.video.domain.vo.VideoTranscodeTaskInfo;
|
||||
import com.hotwj.platform.video.service.VideoChunkUploadService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 视频分片上传与转码接口
|
||||
*/
|
||||
@SaIgnore
|
||||
@Validated
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "视频分片上传", description = "分片上传、合并和转码任务")
|
||||
public class VideoChunkUploadController {
|
||||
|
||||
private final VideoChunkUploadService videoChunkUploadService;
|
||||
|
||||
@GetMapping("/api/check")
|
||||
@Operation(summary = "检查视频分片上传状态")
|
||||
public R<List<Integer>> check(@RequestParam String fileHash) {
|
||||
return R.ok(videoChunkUploadService.checkUploadedChunks(fileHash));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/api/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(summary = "上传视频分片")
|
||||
public R<Void> upload(@Valid VideoChunkUploadBo bo) {
|
||||
videoChunkUploadService.uploadChunk(bo);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/api/merge")
|
||||
@Operation(summary = "合并视频分片并创建转码任务")
|
||||
public R<Map<String, String>> merge(@Valid @RequestBody VideoMergeBo bo) {
|
||||
return R.ok(videoChunkUploadService.mergeAndCreateTask(bo));
|
||||
}
|
||||
|
||||
@GetMapping("/api/task/{taskId}")
|
||||
@Operation(summary = "查询视频转码任务状态")
|
||||
public R<VideoTranscodeTaskInfo> task(@PathVariable String taskId) {
|
||||
return R.ok(videoChunkUploadService.getTaskInfo(taskId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.hotwj.platform.video.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import com.hotwj.platform.video.config.VideoUploadProperties;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 本地 HLS 资源公开访问接口
|
||||
*/
|
||||
@SaIgnore
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class VideoHlsController {
|
||||
|
||||
private final VideoUploadProperties properties;
|
||||
|
||||
@GetMapping("${video.upload.public-url-prefix:/video-hls}/{fileHash}/{fileName:.+}")
|
||||
public void stream(@PathVariable String fileHash, @PathVariable String fileName, HttpServletResponse response) throws IOException {
|
||||
File file = new File(properties.getWorkDir(),
|
||||
"hls" + File.separator + fileHash + File.separator + fileName);
|
||||
if (!file.exists() || !file.isFile()) {
|
||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
if (fileName.contains("..")) {
|
||||
throw new ServiceException("非法文件路径");
|
||||
}
|
||||
MediaType mediaType = MediaTypeFactory.getMediaType(file.getName()).orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||
response.setContentType(mediaType.toString());
|
||||
response.setHeader("Cache-Control", "public, max-age=60");
|
||||
FileCopyUtils.copy(new FileSystemResource(file).getInputStream(), response.getOutputStream());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.hotwj.platform.video.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 视频分片上传参数
|
||||
*/
|
||||
@Data
|
||||
public class VideoChunkUploadBo {
|
||||
|
||||
@NotNull(message = "分片文件不能为空")
|
||||
private MultipartFile chunk;
|
||||
|
||||
@NotBlank(message = "文件哈希不能为空")
|
||||
private String fileHash;
|
||||
|
||||
@NotNull(message = "分片索引不能为空")
|
||||
private Integer chunkIndex;
|
||||
|
||||
@NotNull(message = "总分片数不能为空")
|
||||
private Integer totalChunks;
|
||||
|
||||
@NotBlank(message = "文件名不能为空")
|
||||
private String fileName;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.hotwj.platform.video.domain.bo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 视频分片合并参数
|
||||
*/
|
||||
@Data
|
||||
public class VideoMergeBo {
|
||||
|
||||
@NotBlank(message = "文件哈希不能为空")
|
||||
private String fileHash;
|
||||
|
||||
@NotBlank(message = "文件名不能为空")
|
||||
private String fileName;
|
||||
|
||||
@NotNull(message = "总分片数不能为空")
|
||||
private Integer totalChunks;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.hotwj.platform.video.domain.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 视频转码任务状态
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class VideoTranscodeTaskInfo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String taskId;
|
||||
|
||||
private String fileHash;
|
||||
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* waiting / processing / completed / failed
|
||||
*/
|
||||
private String status;
|
||||
|
||||
private String m3u8Url;
|
||||
|
||||
private String errorMessage;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.hotwj.platform.video.service;
|
||||
|
||||
import com.hotwj.platform.video.domain.bo.VideoChunkUploadBo;
|
||||
import com.hotwj.platform.video.domain.bo.VideoMergeBo;
|
||||
import com.hotwj.platform.video.domain.vo.VideoTranscodeTaskInfo;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface VideoChunkUploadService {
|
||||
|
||||
List<Integer> checkUploadedChunks(String fileHash);
|
||||
|
||||
void uploadChunk(VideoChunkUploadBo bo);
|
||||
|
||||
Map<String, String> mergeAndCreateTask(VideoMergeBo bo);
|
||||
|
||||
VideoTranscodeTaskInfo getTaskInfo(String taskId);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.hotwj.platform.video.service.impl;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.hotwj.platform.video.config.VideoUploadProperties;
|
||||
import com.hotwj.platform.video.domain.bo.VideoChunkUploadBo;
|
||||
import com.hotwj.platform.video.domain.bo.VideoMergeBo;
|
||||
import com.hotwj.platform.video.domain.vo.VideoTranscodeTaskInfo;
|
||||
import com.hotwj.platform.video.service.VideoChunkUploadService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.SpringUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.redisson.api.RSet;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 视频分片上传、合并与异步转码服务
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class VideoChunkUploadServiceImpl implements VideoChunkUploadService {
|
||||
|
||||
private static final String CHUNK_KEY_PREFIX = "video:chunk:";
|
||||
private static final String TASK_KEY_PREFIX = "video:task:";
|
||||
|
||||
private final VideoUploadProperties properties;
|
||||
|
||||
@Override
|
||||
public List<Integer> checkUploadedChunks(String fileHash) {
|
||||
RSet<Integer> rSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + fileHash);
|
||||
Set<Integer> uploaded = rSet.readAll();
|
||||
List<Integer> result = new ArrayList<>(uploaded);
|
||||
result.sort(Integer::compareTo);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void uploadChunk(VideoChunkUploadBo bo) {
|
||||
validateChunkRequest(bo);
|
||||
File chunkDir = getChunkDir(bo.getFileHash());
|
||||
FileUtil.mkdir(chunkDir);
|
||||
File chunkFile = new File(chunkDir, bo.getChunkIndex().toString());
|
||||
try {
|
||||
bo.getChunk().transferTo(chunkFile);
|
||||
} catch (IOException e) {
|
||||
throw new ServiceException("分片上传失败");
|
||||
}
|
||||
RSet<Integer> uploadedSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + bo.getFileHash());
|
||||
uploadedSet.add(bo.getChunkIndex());
|
||||
uploadedSet.expire(properties.chunkExpireDuration());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> mergeAndCreateTask(VideoMergeBo bo) {
|
||||
validateMergeRequest(bo);
|
||||
RSet<Integer> uploadedSet = RedisUtils.getClient().getSet(CHUNK_KEY_PREFIX + bo.getFileHash());
|
||||
Set<Integer> uploaded = uploadedSet.readAll();
|
||||
for (int i = 0; i < bo.getTotalChunks(); i++) {
|
||||
if (!uploaded.contains(i)) {
|
||||
throw new ServiceException("存在未上传完成的分片,无法合并");
|
||||
}
|
||||
}
|
||||
|
||||
File sourceDir = getSourceDir(bo.getFileHash());
|
||||
FileUtil.mkdir(sourceDir);
|
||||
File mergedFile = new File(sourceDir, sanitizeFileName(bo.getFileName()));
|
||||
mergeChunksToFile(bo.getFileHash(), bo.getTotalChunks(), mergedFile.toPath());
|
||||
|
||||
String taskId = "task_" + IdUtil.fastSimpleUUID();
|
||||
VideoTranscodeTaskInfo taskInfo = new VideoTranscodeTaskInfo()
|
||||
.setTaskId(taskId)
|
||||
.setFileHash(bo.getFileHash())
|
||||
.setFileName(bo.getFileName())
|
||||
.setStatus("waiting")
|
||||
.setCreateTime(LocalDateTime.now())
|
||||
.setUpdateTime(LocalDateTime.now());
|
||||
saveTask(taskInfo);
|
||||
SpringUtils.getAopProxy(this).startTranscodeAsync(taskId, bo.getFileHash(), mergedFile.toPath());
|
||||
return Collections.singletonMap("taskId", taskId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public VideoTranscodeTaskInfo getTaskInfo(String taskId) {
|
||||
VideoTranscodeTaskInfo taskInfo = RedisUtils.getCacheObject(TASK_KEY_PREFIX + taskId);
|
||||
if (taskInfo == null) {
|
||||
throw new ServiceException("任务不存在或已过期");
|
||||
}
|
||||
return taskInfo;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void startTranscodeAsync(String taskId, String fileHash, Path sourceFile) {
|
||||
VideoTranscodeTaskInfo taskInfo = getTaskInfo(taskId);
|
||||
try {
|
||||
taskInfo.setStatus("processing").setUpdateTime(LocalDateTime.now()).setErrorMessage(null);
|
||||
saveTask(taskInfo);
|
||||
|
||||
Path outputDir = getHlsDir(fileHash).toPath();
|
||||
FileUtil.mkdir(outputDir.toFile());
|
||||
cleanDirectory(outputDir);
|
||||
|
||||
Path playlistPath = outputDir.resolve("index.m3u8");
|
||||
runFfmpeg(sourceFile, outputDir, playlistPath);
|
||||
|
||||
taskInfo.setStatus("completed")
|
||||
.setM3u8Url(buildPublicM3u8Url(fileHash))
|
||||
.setUpdateTime(LocalDateTime.now())
|
||||
.setErrorMessage(null);
|
||||
saveTask(taskInfo);
|
||||
|
||||
FileUtil.del(getChunkDir(fileHash));
|
||||
log.info("视频转码完成: taskId={}, fileHash={}, playlist={}", taskId, fileHash, playlistPath);
|
||||
} catch (Exception e) {
|
||||
log.error("视频转码失败: taskId={}, fileHash={}, msg={}", taskId, fileHash, e.getMessage(), e);
|
||||
taskInfo.setStatus("failed")
|
||||
.setErrorMessage(StringUtils.substring(e.getMessage(), 0, 500))
|
||||
.setUpdateTime(LocalDateTime.now());
|
||||
saveTask(taskInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateChunkRequest(VideoChunkUploadBo bo) {
|
||||
if (bo.getChunkIndex() < 0) {
|
||||
throw new ServiceException("分片索引不能小于0");
|
||||
}
|
||||
if (bo.getTotalChunks() <= 0) {
|
||||
throw new ServiceException("总分片数必须大于0");
|
||||
}
|
||||
if (bo.getChunkIndex() >= bo.getTotalChunks()) {
|
||||
throw new ServiceException("分片索引超出范围");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateMergeRequest(VideoMergeBo bo) {
|
||||
if (bo.getTotalChunks() <= 0) {
|
||||
throw new ServiceException("总分片数必须大于0");
|
||||
}
|
||||
}
|
||||
|
||||
private void mergeChunksToFile(String fileHash, int totalChunks, Path targetFile) {
|
||||
try {
|
||||
Files.deleteIfExists(targetFile);
|
||||
Files.createDirectories(targetFile.getParent());
|
||||
try (OutputStream outputStream = Files.newOutputStream(targetFile)) {
|
||||
for (int i = 0; i < totalChunks; i++) {
|
||||
Path chunkPath = getChunkDir(fileHash).toPath().resolve(String.valueOf(i));
|
||||
if (!Files.exists(chunkPath)) {
|
||||
throw new ServiceException("缺少分片: " + i);
|
||||
}
|
||||
try (InputStream inputStream = Files.newInputStream(chunkPath)) {
|
||||
inputStream.transferTo(outputStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new ServiceException("合并分片失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void runFfmpeg(Path sourceFile, Path outputDir, Path playlistPath) throws IOException, InterruptedException {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(properties.getFfmpegCommand());
|
||||
command.add("-y");
|
||||
command.add("-i");
|
||||
command.add(sourceFile.toAbsolutePath().toString());
|
||||
command.add("-profile:v");
|
||||
command.add("baseline");
|
||||
command.add("-level");
|
||||
command.add("3.0");
|
||||
command.add("-start_number");
|
||||
command.add("0");
|
||||
command.add("-hls_time");
|
||||
command.add(String.valueOf(properties.getHlsSegmentSeconds() == null ? 10 : properties.getHlsSegmentSeconds()));
|
||||
command.add("-hls_list_size");
|
||||
command.add("0");
|
||||
command.add("-hls_segment_filename");
|
||||
command.add(outputDir.resolve("segment_%05d.ts").toAbsolutePath().toString());
|
||||
command.add("-f");
|
||||
command.add("hls");
|
||||
command.add(playlistPath.toAbsolutePath().toString());
|
||||
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||
processBuilder.redirectErrorStream(true);
|
||||
Process process = processBuilder.start();
|
||||
String output;
|
||||
try (InputStream inputStream = process.getInputStream()) {
|
||||
output = new String(inputStream.readAllBytes());
|
||||
}
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0 || !Files.exists(playlistPath)) {
|
||||
throw new ServiceException("FFmpeg 转码失败: " + abbreviate(output, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
private void saveTask(VideoTranscodeTaskInfo taskInfo) {
|
||||
RedisUtils.setCacheObject(TASK_KEY_PREFIX + taskInfo.getTaskId(), taskInfo, properties.taskExpireDuration());
|
||||
}
|
||||
|
||||
private String buildPublicM3u8Url(String fileHash) {
|
||||
String relativePath = properties.normalizedPublicUrlPrefix() + "/" + fileHash + "/index.m3u8";
|
||||
if (StringUtils.isBlank(properties.getPublicBaseUrl())) {
|
||||
return relativePath;
|
||||
}
|
||||
String baseUrl = properties.getPublicBaseUrl().trim();
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
}
|
||||
return baseUrl + relativePath;
|
||||
}
|
||||
|
||||
private void cleanDirectory(Path directory) throws IOException {
|
||||
if (!Files.exists(directory)) {
|
||||
return;
|
||||
}
|
||||
try (var stream = Files.list(directory)) {
|
||||
stream.forEach(path -> FileUtil.del(path.toFile()));
|
||||
}
|
||||
}
|
||||
|
||||
private File getChunkDir(String fileHash) {
|
||||
return new File(properties.getWorkDir(), "chunks" + File.separator + fileHash);
|
||||
}
|
||||
|
||||
private File getSourceDir(String fileHash) {
|
||||
return new File(properties.getWorkDir(), "source" + File.separator + fileHash);
|
||||
}
|
||||
|
||||
private File getHlsDir(String fileHash) {
|
||||
return new File(properties.getWorkDir(), "hls" + File.separator + fileHash);
|
||||
}
|
||||
|
||||
private String sanitizeFileName(String fileName) {
|
||||
return fileName.replace("\\", "_").replace("/", "_");
|
||||
}
|
||||
|
||||
private String abbreviate(String text, int maxLength) {
|
||||
if (text == null || text.length() <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user