From 1916dd96a275b7a70224c88ae996c8564f714228 Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 26 Dec 2025 15:37:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(video):=20=E6=B7=BB=E5=8A=A0=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AFHLS=E8=A7=86=E9=A2=91=E6=B5=81=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=88=97=E8=A1=A8=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现AppVideoStreamController提供HLS播放列表生成接口 - 添加HlsStreamRequest和HlsStreamResponse数据传输对象 - 实现HlsStreamService服务类处理视频流逻辑 - 支持生成JSON格式和m3u8文件格式的播放列表 - 提供视频片段查询和设备视频HLS播放列表生成功能 - 支持EVENT和VOD两种播放列表类型 - 集成设备存储操作器获取视频文件列表 - 实现播放列表内容构建和视频片段时长计算功能 --- .../manage/AppVideoStreamController.java | 150 ++++++++++++ .../mobile/video/dto/HlsStreamRequest.java | 35 +++ .../mobile/video/dto/HlsStreamResponse.java | 86 +++++++ .../service/mobile/HlsStreamService.java | 231 ++++++++++++++++++ 4 files changed, 502 insertions(+) create mode 100644 src/main/java/com/ycwl/basic/controller/mobile/manage/AppVideoStreamController.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/video/dto/HlsStreamRequest.java create mode 100644 src/main/java/com/ycwl/basic/model/mobile/video/dto/HlsStreamResponse.java create mode 100644 src/main/java/com/ycwl/basic/service/mobile/HlsStreamService.java diff --git a/src/main/java/com/ycwl/basic/controller/mobile/manage/AppVideoStreamController.java b/src/main/java/com/ycwl/basic/controller/mobile/manage/AppVideoStreamController.java new file mode 100644 index 00000000..10310b38 --- /dev/null +++ b/src/main/java/com/ycwl/basic/controller/mobile/manage/AppVideoStreamController.java @@ -0,0 +1,150 @@ +package com.ycwl.basic.controller.mobile.manage; + +import com.ycwl.basic.annotation.IgnoreToken; +import com.ycwl.basic.model.mobile.video.dto.HlsStreamRequest; +import com.ycwl.basic.model.mobile.video.dto.HlsStreamResponse; +import com.ycwl.basic.service.mobile.HlsStreamService; +import com.ycwl.basic.utils.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * 移动端视频流控制器 + * 提供HLS视频流播放列表生成功能 + * + * @author Claude Code + * @date 2025-12-26 + */ +@Slf4j +@RestController +@RequestMapping("/api/mobile/video-stream/v1") +@RequiredArgsConstructor +public class AppVideoStreamController { + + private final HlsStreamService hlsStreamService; + + /** + * 生成设备视频的HLS播放列表(JSON格式) + * 返回包含m3u8内容和视频片段信息的JSON对象 + * + * @param request HLS流请求参数 + * @return HLS播放列表响应 + */ + @PostMapping("/hls/playlist") + public ApiResponse generateHlsPlaylist(@Validated @RequestBody HlsStreamRequest request) { + log.info("收到HLS播放列表生成请求: deviceId={}, durationMinutes={}", + request.getDeviceId(), request.getDurationMinutes()); + + try { + HlsStreamResponse response = hlsStreamService.generateHlsPlaylist(request); + log.info("HLS播放列表生成成功: deviceId={}, segmentCount={}, totalDuration={}s", + response.getDeviceId(), response.getSegmentCount(), response.getTotalDurationSeconds()); + + return ApiResponse.success(response); + + } catch (Exception e) { + log.error("生成HLS播放列表失败: deviceId={}", request.getDeviceId(), e); + return ApiResponse.buildResponse(500, null, "生成失败: " + e.getMessage()); + } + } + + /** + * 生成设备视频的HLS播放列表(m3u8文件格式) + * 直接返回m3u8文件内容,可被视频播放器直接使用 + * + * @param deviceId 设备ID + * @param durationMinutes 视频时长(分钟),默认2分钟 + * @param eventPlaylist 是否为Event播放列表,默认true + * @param response HTTP响应对象 + */ + @GetMapping("/hls/playlist.m3u8") + @IgnoreToken + public void generateHlsPlaylistFile( + @RequestParam Long deviceId, + @RequestParam(defaultValue = "2") Integer durationMinutes, + @RequestParam(defaultValue = "true") Boolean eventPlaylist, + HttpServletResponse response) { + + log.info("收到m3u8文件生成请求: deviceId={}, durationMinutes={}", + deviceId, durationMinutes); + + try { + // 构建请求参数 + HlsStreamRequest request = new HlsStreamRequest(); + request.setDeviceId(deviceId); + request.setDurationMinutes(durationMinutes); + request.setEventPlaylist(eventPlaylist); + + // 生成播放列表 + HlsStreamResponse hlsResponse = hlsStreamService.generateHlsPlaylist(request); + + log.info("m3u8文件生成成功: deviceId={}, segmentCount={}, totalDuration={}s", + deviceId, hlsResponse.getSegmentCount(), hlsResponse.getTotalDurationSeconds()); + + // 设置响应头 + response.setContentType("application/vnd.apple.mpegurl"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setHeader("Content-Disposition", "inline; filename=\"playlist.m3u8\""); + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.setHeader("Pragma", "no-cache"); + response.setHeader("Expires", "0"); + + // 写入m3u8内容 + response.getWriter().write(hlsResponse.getPlaylistContent()); + response.getWriter().flush(); + + } catch (Exception e) { + log.error("生成m3u8文件失败: deviceId={}", deviceId, e); + try { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write("{\"code\":500,\"message\":\"生成失败: " + e.getMessage() + "\"}"); + response.getWriter().flush(); + } catch (IOException ioException) { + log.error("写入错误响应失败", ioException); + } + } + } + + /** + * 获取设备最近的视频片段信息 + * 仅返回视频片段列表,不包含m3u8内容 + * + * @param deviceId 设备ID + * @param durationMinutes 视频时长(分钟),默认2分钟 + * @return 视频片段列表 + */ + @GetMapping("/segments") + public ApiResponse getVideoSegments( + @RequestParam Long deviceId, + @RequestParam(defaultValue = "2") Integer durationMinutes) { + + log.info("收到视频片段查询请求: deviceId={}, durationMinutes={}", + deviceId, durationMinutes); + + try { + HlsStreamRequest request = new HlsStreamRequest(); + request.setDeviceId(deviceId); + request.setDurationMinutes(durationMinutes); + request.setEventPlaylist(true); + + HlsStreamResponse response = hlsStreamService.generateHlsPlaylist(request); + log.info("视频片段查询成功: deviceId={}, segmentCount={}", + deviceId, response.getSegmentCount()); + + return ApiResponse.success(response); + + } catch (Exception e) { + log.error("查询视频片段失败: deviceId={}", deviceId, e); + return ApiResponse.buildResponse(500, null, "查询失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/video/dto/HlsStreamRequest.java b/src/main/java/com/ycwl/basic/model/mobile/video/dto/HlsStreamRequest.java new file mode 100644 index 00000000..1547c988 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/video/dto/HlsStreamRequest.java @@ -0,0 +1,35 @@ +package com.ycwl.basic.model.mobile.video.dto; + +import lombok.Data; + +import jakarta.validation.constraints.NotNull; + +/** + * HLS视频流请求参数 + * 用于生成设备视频的HLS播放列表 + * + * @author Claude Code + * @date 2025-12-26 + */ +@Data +public class HlsStreamRequest { + + /** + * 设备ID + */ + @NotNull(message = "设备ID不能为空") + private Long deviceId; + + /** + * 视频时长(分钟),默认2分钟 + * 获取最近N分钟的视频 + */ + private Integer durationMinutes = 2; + + /** + * 是否为Event播放列表 + * true: 使用EVENT类型(适合固定时长的视频回放) + * false: 使用VOD类型(适合点播) + */ + private Boolean eventPlaylist = true; +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/video/dto/HlsStreamResponse.java b/src/main/java/com/ycwl/basic/model/mobile/video/dto/HlsStreamResponse.java new file mode 100644 index 00000000..ad0ad132 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/video/dto/HlsStreamResponse.java @@ -0,0 +1,86 @@ +package com.ycwl.basic.model.mobile.video.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * HLS视频流响应 + * 包含生成的m3u8播放列表内容 + * + * @author Claude Code + * @date 2025-12-26 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HlsStreamResponse { + + /** + * 设备ID + */ + private Long deviceId; + + /** + * m3u8播放列表内容 + */ + private String playlistContent; + + /** + * 视频片段数量 + */ + private Integer segmentCount; + + /** + * 总时长(秒) + */ + private Double totalDurationSeconds; + + /** + * 视频片段列表 + */ + private List segments; + + /** + * 播放列表类型(EVENT/VOD) + */ + private String playlistType; + + /** + * 视频片段信息 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class VideoSegment { + /** + * 视频片段URL + */ + private String url; + + /** + * 片段时长(秒) + */ + private Double duration; + + /** + * 片段序号 + */ + private Integer sequence; + + /** + * 片段开始时间 + */ + private String startTime; + + /** + * 片段结束时间 + */ + private String endTime; + } +} diff --git a/src/main/java/com/ycwl/basic/service/mobile/HlsStreamService.java b/src/main/java/com/ycwl/basic/service/mobile/HlsStreamService.java new file mode 100644 index 00000000..839f6d50 --- /dev/null +++ b/src/main/java/com/ycwl/basic/service/mobile/HlsStreamService.java @@ -0,0 +1,231 @@ +package com.ycwl.basic.service.mobile; + +import com.ycwl.basic.device.DeviceFactory; +import com.ycwl.basic.device.entity.common.FileObject; +import com.ycwl.basic.device.operator.IDeviceStorageOperator; +import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO; +import com.ycwl.basic.integration.device.service.DeviceIntegrationService; +import com.ycwl.basic.model.mobile.video.dto.HlsStreamRequest; +import com.ycwl.basic.model.mobile.video.dto.HlsStreamResponse; +import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity; +import com.ycwl.basic.repository.DeviceRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.stream.Collectors; + +/** + * HLS视频流服务 + * 用于生成设备视频的HLS播放列表(m3u8) + * + * @author Claude Code + * @date 2025-12-26 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HlsStreamService { + + private final DeviceIntegrationService deviceIntegrationService; + private final DeviceRepository deviceRepository; + + private static final String M3U8_HEADER = "#EXTM3U"; + private static final String M3U8_VERSION = "#EXT-X-VERSION:3"; + private static final String M3U8_MEDIA_SEQUENCE = "#EXT-X-MEDIA-SEQUENCE:0"; + private static final String M3U8_ALLOW_CACHE = "#EXT-X-ALLOW-CACHE:YES"; + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + /** + * 生成设备视频的HLS播放列表 + * + * @param request HLS流请求参数 + * @return HLS播放列表响应 + */ + public HlsStreamResponse generateHlsPlaylist(HlsStreamRequest request) { + log.info("开始生成HLS播放列表: deviceId={}, durationMinutes={}", + request.getDeviceId(), request.getDurationMinutes()); + + try { + // 获取设备信息 + DeviceV2DTO device = deviceIntegrationService.getDevice(request.getDeviceId()); + if (device == null) { + throw new RuntimeException("设备不存在: " + request.getDeviceId()); + } + + // 获取设备配置 + DeviceConfigEntity config = deviceRepository.getDeviceConfig(request.getDeviceId()); + if (config == null) { + throw new RuntimeException("设备配置不存在: " + request.getDeviceId()); + } + + // 获取设备的存储操作器 + IDeviceStorageOperator operator = DeviceFactory.getDeviceStorageOperator(device, config); + if (operator == null) { + throw new RuntimeException("设备未配置存储操作器: " + request.getDeviceId()); + } + + // 计算时间范围:当前时间向前N分钟 + Calendar calendar = Calendar.getInstance(); + Date endDate = calendar.getTime(); + calendar.add(Calendar.MINUTE, -request.getDurationMinutes()); + Date startDate = calendar.getTime(); + + log.info("查询视频文件范围: deviceId={}, startDate={}, endDate={}", + request.getDeviceId(), DATE_FORMAT.format(startDate), DATE_FORMAT.format(endDate)); + + // 获取视频文件列表 + List fileList = operator.getFileListByDtRange(startDate, endDate); + if (fileList == null || fileList.isEmpty()) { + log.warn("未找到视频文件: deviceId={}, startDate={}, endDate={}", + request.getDeviceId(), DATE_FORMAT.format(startDate), DATE_FORMAT.format(endDate)); + return buildEmptyResponse(request.getDeviceId(), request.getEventPlaylist()); + } + + // 按创建时间排序(升序) + fileList.sort(Comparator.comparing(FileObject::getCreateTime)); + + log.info("找到 {} 个视频文件", fileList.size()); + + // 生成播放列表 + return buildHlsResponse(request.getDeviceId(), fileList, request.getEventPlaylist()); + + } catch (Exception e) { + log.error("生成HLS播放列表失败: deviceId={}", request.getDeviceId(), e); + throw new RuntimeException("生成HLS播放列表失败: " + e.getMessage(), e); + } + } + + /** + * 构建HLS响应 + * + * @param deviceId 设备ID + * @param fileList 视频文件列表 + * @param isEventPlaylist 是否为Event播放列表 + * @return HLS响应 + */ + private HlsStreamResponse buildHlsResponse(Long deviceId, List fileList, Boolean isEventPlaylist) { + StringBuilder m3u8Content = new StringBuilder(); + List segments = new ArrayList<>(); + + // 添加m3u8头部 + m3u8Content.append(M3U8_HEADER).append("\n"); + m3u8Content.append(M3U8_VERSION).append("\n"); + + // 设置播放列表类型 + String playlistType = Boolean.TRUE.equals(isEventPlaylist) ? "EVENT" : "VOD"; + m3u8Content.append("#EXT-X-PLAYLIST-TYPE:").append(playlistType).append("\n"); + + // 计算目标时长(使用视频片段的平均时长) + double avgDuration = calculateAverageDuration(fileList); + int targetDuration = (int) Math.ceil(avgDuration); + m3u8Content.append("#EXT-X-TARGETDURATION:").append(targetDuration).append("\n"); + + m3u8Content.append(M3U8_MEDIA_SEQUENCE).append("\n"); + m3u8Content.append(M3U8_ALLOW_CACHE).append("\n"); + + // 添加视频片段 + double totalDuration = 0.0; + int sequence = 0; + + for (FileObject file : fileList) { + // 计算片段时长(秒) + double duration = calculateSegmentDuration(file); + totalDuration += duration; + + // 添加片段信息到m3u8 + m3u8Content.append("#EXTINF:").append(String.format("%.3f", duration)).append(",\n"); + m3u8Content.append(file.getUrl().replace("-internal.aliyuncs.com", ".aliyuncs.com")).append("\n"); + + // 记录片段信息 + HlsStreamResponse.VideoSegment segment = HlsStreamResponse.VideoSegment.builder() + .url(file.getUrl()) + .duration(duration) + .sequence(sequence++) + .startTime(file.getCreateTime() != null ? DATE_FORMAT.format(file.getCreateTime()) : null) + .endTime(file.getEndTime() != null ? DATE_FORMAT.format(file.getEndTime()) : null) + .build(); + segments.add(segment); + } + + // 添加结束标记(VOD类型需要) + if (!"EVENT".equals(playlistType)) { + m3u8Content.append("#EXT-X-ENDLIST\n"); + } + + return HlsStreamResponse.builder() + .deviceId(deviceId) + .playlistContent(m3u8Content.toString()) + .segmentCount(fileList.size()) + .totalDurationSeconds(totalDuration) + .segments(segments) + .playlistType(playlistType) + .build(); + } + + /** + * 构建空响应 + */ + private HlsStreamResponse buildEmptyResponse(Long deviceId, Boolean isEventPlaylist) { + String playlistType = Boolean.TRUE.equals(isEventPlaylist) ? "EVENT" : "VOD"; + StringBuilder m3u8Content = new StringBuilder(); + + m3u8Content.append(M3U8_HEADER).append("\n"); + m3u8Content.append(M3U8_VERSION).append("\n"); + m3u8Content.append("#EXT-X-PLAYLIST-TYPE:").append(playlistType).append("\n"); + m3u8Content.append("#EXT-X-TARGETDURATION:0\n"); + m3u8Content.append(M3U8_MEDIA_SEQUENCE).append("\n"); + + if (!"EVENT".equals(playlistType)) { + m3u8Content.append("#EXT-X-ENDLIST\n"); + } + + return HlsStreamResponse.builder() + .deviceId(deviceId) + .playlistContent(m3u8Content.toString()) + .segmentCount(0) + .totalDurationSeconds(0.0) + .segments(Collections.emptyList()) + .playlistType(playlistType) + .build(); + } + + /** + * 计算视频片段的平均时长(秒) + */ + private double calculateAverageDuration(List fileList) { + if (fileList == null || fileList.isEmpty()) { + return 10.0; // 默认10秒 + } + + List durations = fileList.stream() + .map(this::calculateSegmentDuration) + .filter(d -> d > 0) + .collect(Collectors.toList()); + + if (durations.isEmpty()) { + return 10.0; // 默认10秒 + } + + return durations.stream() + .mapToDouble(Double::doubleValue) + .average() + .orElse(10.0); + } + + /** + * 计算单个视频片段的时长(秒) + */ + private double calculateSegmentDuration(FileObject file) { + if (file.getCreateTime() != null && file.getEndTime() != null) { + long durationMs = file.getEndTime().getTime() - file.getCreateTime().getTime(); + if (durationMs > 0) { + return durationMs / 1000.0; + } + } + // 如果无法计算,返回默认值10秒 + return 10.0; + } +}