| New file |
| | |
| | | package cc.mrbird.febs.ai.controller.productPoint; |
| | | |
| | | import cc.mrbird.febs.common.utils.LoginUserUtil; |
| | | import cn.hutool.core.util.StrUtil; |
| | | import io.swagger.annotations.Api; |
| | | import lombok.RequiredArgsConstructor; |
| | | import lombok.extern.slf4j.Slf4j; |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.validation.annotation.Validated; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.PathVariable; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import javax.servlet.http.HttpServletRequest; |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.io.InputStream; |
| | | import java.io.OutputStream; |
| | | import java.io.RandomAccessFile; |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.nio.file.Paths; |
| | | |
| | | @Slf4j |
| | | @Validated |
| | | @RestController |
| | | @RequiredArgsConstructor |
| | | @RequestMapping(value = "/api/ai/videoPlay") |
| | | @Api(value = "VideoPlayController", tags = "AI-视屏播放") |
| | | public class VideoPlayController { |
| | | |
| | | public static final String baseUploadDir = "/home/javaweb/webresource/ai/file"; |
| | | |
| | | @GetMapping("/play/{fileName}") |
| | | public void playVideo(@PathVariable("fileName") String fileName, HttpServletRequest request, HttpServletResponse response) { |
| | | log.info("开始播放视频文件: {}", fileName); |
| | | |
| | | // 配置参数 |
| | | final int BUFFER_SIZE = 1024 * 1024; // 1MB缓冲区,提高I/O性能 |
| | | final long MAX_INITIAL_SEGMENT = 1024 * 1024 * 15; // 最大初始片段大小(15MB),约30秒中等码率视频 |
| | | |
| | | try { |
| | | String companyId = LoginUserUtil.getLoginUser().getCompanyId(); |
| | | |
| | | // 检查文件是否存在 |
| | | if (StrUtil.isBlank(companyId)) { |
| | | response.setStatus(HttpStatus.NOT_FOUND.value()); |
| | | return; |
| | | } |
| | | |
| | | String uploadDir = baseUploadDir + "/" + companyId; |
| | | Path filePath = Paths.get(uploadDir, fileName); |
| | | |
| | | // 检查文件是否存在 |
| | | if (!Files.exists(filePath)) { |
| | | log.warn("视频文件不存在: {}", filePath); |
| | | response.setStatus(HttpStatus.NOT_FOUND.value()); |
| | | return; |
| | | } |
| | | |
| | | // 检查文件是否为常规文件 |
| | | if (!Files.isRegularFile(filePath)) { |
| | | log.warn("路径不是常规文件: {}", filePath); |
| | | response.setStatus(HttpStatus.BAD_REQUEST.value()); |
| | | return; |
| | | } |
| | | |
| | | // 获取文件大小 |
| | | long fileSize = Files.size(filePath); |
| | | log.debug("视频文件大小: {} bytes", fileSize); |
| | | |
| | | // 检查是否有Range请求头 |
| | | String rangeHeader = request.getHeader("Range"); |
| | | if (rangeHeader != null) { |
| | | log.debug("收到Range请求: {}", rangeHeader); |
| | | // 解析Range头,格式为"bytes=start-end" |
| | | String[] ranges = rangeHeader.replace("bytes=", "").split("-"); |
| | | long start = 0, end = fileSize - 1; |
| | | |
| | | try { |
| | | if (!ranges[0].isEmpty()) { |
| | | start = Long.parseLong(ranges[0]); |
| | | } |
| | | if (ranges.length > 1 && !ranges[1].isEmpty()) { |
| | | end = Long.parseLong(ranges[1]); |
| | | } else { |
| | | // 如果没有指定结束位置,返回从开始位置到文件末尾的内容 |
| | | // 但限制最大长度,避免一次性传输过大的内容 |
| | | end = Math.min(start + BUFFER_SIZE * 10, fileSize - 1); |
| | | } |
| | | } catch (NumberFormatException e) { |
| | | log.error("无效的Range头格式: {}", rangeHeader, e); |
| | | response.setStatus(HttpStatus.BAD_REQUEST.value()); |
| | | return; |
| | | } |
| | | |
| | | // 确保范围有效 |
| | | if (start > end || start >= fileSize) { |
| | | log.warn("无效的Range范围: {}-{},文件大小: {}", start, end, fileSize); |
| | | response.setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value()); |
| | | response.setHeader("Content-Range", "bytes */" + fileSize); |
| | | return; |
| | | } |
| | | |
| | | // 限制结束位置不超过文件大小 |
| | | if (end >= fileSize) { |
| | | end = fileSize - 1; |
| | | } |
| | | |
| | | // 计算内容长度 |
| | | long contentLength = end - start + 1; |
| | | log.debug("处理Range请求: {}-{},长度: {}", start, end, contentLength); |
| | | |
| | | // 设置响应状态和头部信息 |
| | | response.setStatus(HttpStatus.PARTIAL_CONTENT.value()); |
| | | response.setContentType(getContentTypeByExtension(fileName)); |
| | | response.setContentLengthLong(contentLength); |
| | | response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize); |
| | | response.setHeader("Accept-Ranges", "bytes"); |
| | | response.setHeader("Content-Disposition", "inline; filename=\"" + fileName + "\""); |
| | | response.setHeader("Cache-Control", "public, max-age=3600"); // 添加缓存控制 |
| | | |
| | | // 使用NIO提高读取性能 |
| | | try (RandomAccessFile randomAccessFile = new RandomAccessFile(filePath.toFile(), "r"); |
| | | OutputStream outputStream = response.getOutputStream()) { |
| | | |
| | | randomAccessFile.seek(start); |
| | | byte[] buffer = new byte[BUFFER_SIZE]; |
| | | long remaining = contentLength; |
| | | int bytesRead; |
| | | |
| | | while (remaining > 0 && (bytesRead = randomAccessFile.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) { |
| | | outputStream.write(buffer, 0, bytesRead); |
| | | remaining -= bytesRead; |
| | | |
| | | // 定期刷新输出流,避免缓冲区溢出 |
| | | if (remaining % (BUFFER_SIZE * 4) == 0) { |
| | | outputStream.flush(); |
| | | } |
| | | } |
| | | |
| | | // 确保所有数据都被写入 |
| | | outputStream.flush(); |
| | | log.debug("Range请求处理完成: {}-{}", start, end); |
| | | } |
| | | } else { |
| | | log.debug("收到完整文件请求,返回初始片段"); |
| | | |
| | | // 没有Range头,返回文件大小的10分之一和15MB中较小的那个 |
| | | long oneTenthSize = fileSize / 10; |
| | | // 计算10分之一和15MB的较小值 |
| | | long maxInitialSize = Math.min(oneTenthSize, MAX_INITIAL_SEGMENT); |
| | | // 确保至少返回1字节,且不超过文件大小 |
| | | long actualEnd = Math.min(Math.max(maxInitialSize, 1), fileSize - 1); |
| | | log.debug("返回初始片段: {} bytes (10分之一大小: {} bytes, 15MB限制: {} bytes, 文件总大小: {} bytes)", |
| | | actualEnd + 1, oneTenthSize, MAX_INITIAL_SEGMENT, fileSize); |
| | | |
| | | // 设置响应状态和头部信息 |
| | | response.setStatus(HttpStatus.PARTIAL_CONTENT.value()); // 使用206状态码,因为只返回部分内容 |
| | | response.setContentType(getContentTypeByExtension(fileName)); |
| | | response.setContentLengthLong(actualEnd + 1); |
| | | response.setHeader("Content-Range", "bytes 0-" + actualEnd + "/" + fileSize); |
| | | response.setHeader("Accept-Ranges", "bytes"); |
| | | response.setHeader("Content-Disposition", "inline; filename=\"" + fileName + "\""); |
| | | response.setHeader("Cache-Control", "public, max-age=3600"); // 添加缓存控制 |
| | | |
| | | // 流式传输文件的初始片段 |
| | | try (InputStream inputStream = Files.newInputStream(filePath); |
| | | OutputStream outputStream = response.getOutputStream()) { |
| | | byte[] buffer = new byte[BUFFER_SIZE]; |
| | | long written = 0; |
| | | int bytesRead; |
| | | |
| | | while (written <= actualEnd && (bytesRead = inputStream.read(buffer)) != -1) { |
| | | long toWrite = Math.min(bytesRead, actualEnd + 1 - written); |
| | | outputStream.write(buffer, 0, (int)toWrite); |
| | | written += toWrite; |
| | | |
| | | // 定期刷新输出流 |
| | | if (written % (BUFFER_SIZE * 4) == 0) { |
| | | outputStream.flush(); |
| | | } |
| | | |
| | | // 如果已经写完初始片段,则停止 |
| | | if (written > actualEnd) { |
| | | break; |
| | | } |
| | | } |
| | | |
| | | // 确保所有数据都被写入 |
| | | outputStream.flush(); |
| | | log.debug("初始片段传输完成,大小: {} bytes", written); |
| | | } |
| | | } |
| | | } catch (Exception e) { |
| | | log.error("播放视频文件时发生错误: {}", fileName, e); |
| | | try { |
| | | // 确保响应状态被设置 |
| | | if (!response.isCommitted()) { |
| | | response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); |
| | | } |
| | | } catch (Exception ex) { |
| | | log.error("设置响应状态时发生错误", ex); |
| | | } |
| | | } finally { |
| | | log.info("视频播放请求处理完成: {}", fileName); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 根据文件扩展名获取MIME类型 |
| | | */ |
| | | private String getContentTypeByExtension(String fileName) { |
| | | String extension = ""; |
| | | int lastDotIndex = fileName.lastIndexOf('.'); |
| | | if (lastDotIndex > 0) { |
| | | extension = fileName.substring(lastDotIndex).toLowerCase(); |
| | | } |
| | | |
| | | switch (extension) { |
| | | case ".mp4": |
| | | return "video/mp4"; |
| | | case ".avi": |
| | | return "video/x-msvideo"; |
| | | case ".mov": |
| | | return "video/quicktime"; |
| | | case ".wmv": |
| | | return "video/x-ms-wmv"; |
| | | case ".flv": |
| | | return "video/x-flv"; |
| | | case ".webm": |
| | | return "video/webm"; |
| | | case ".mkv": |
| | | return "video/x-matroska"; |
| | | case ".m3u8": |
| | | return "application/vnd.apple.mpegurl"; |
| | | case ".ts": |
| | | return "video/MP2T"; |
| | | default: |
| | | return "application/octet-stream"; |
| | | } |
| | | } |
| | | } |