feat(video): 添加移动端HLS视频流播放列表生成功能

- 实现AppVideoStreamController提供HLS播放列表生成接口
- 添加HlsStreamRequest和HlsStreamResponse数据传输对象
- 实现HlsStreamService服务类处理视频流逻辑
- 支持生成JSON格式和m3u8文件格式的播放列表
- 提供视频片段查询和设备视频HLS播放列表生成功能
- 支持EVENT和VOD两种播放列表类型
- 集成设备存储操作器获取视频文件列表
- 实现播放列表内容构建和视频片段时长计算功能
This commit is contained in:
2025-12-26 15:37:22 +08:00
parent c583d4b007
commit 1916dd96a2
4 changed files with 502 additions and 0 deletions

View File

@@ -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<FileObject> 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<FileObject> fileList, Boolean isEventPlaylist) {
StringBuilder m3u8Content = new StringBuilder();
List<HlsStreamResponse.VideoSegment> 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<FileObject> fileList) {
if (fileList == null || fileList.isEmpty()) {
return 10.0; // 默认10秒
}
List<Double> 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;
}
}