diff --git a/src/main/java/com/hotwj/platform/system/maintenance/config/MediaResourceMetadataRepairProperties.java b/src/main/java/com/hotwj/platform/system/maintenance/config/MediaResourceMetadataRepairProperties.java new file mode 100644 index 0000000..69c3ec4 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/config/MediaResourceMetadataRepairProperties.java @@ -0,0 +1,129 @@ +package com.hotwj.platform.system.maintenance.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * 媒体资源元数据修复配置 + */ +@Data +@Component +@ConfigurationProperties(prefix = "maintenance.media-resource-repair") +public class MediaResourceMetadataRepairProperties { + + /** + * 任务缓存key前缀 + */ + private String taskKeyPrefix = "maintenance:media-resource-repair:"; + + /** + * 临时目录根路径 + */ + private String workDir = System.getProperty("java.io.tmpdir") + File.separator + "media-resource-repair"; + + /** + * ffprobe 命令 + */ + private String ffprobeCommand = "ffprobe"; + + /** + * ffmpeg 命令 + */ + private String ffmpegCommand = "ffmpeg"; + + /** + * 任务状态保留小时数 + */ + private Integer taskExpireHours = 24; + + /** + * 默认单次处理上限 + */ + private Integer defaultLimit = 200; + + /** + * 最大单次处理上限 + */ + private Integer maxLimit = 500; + + /** + * 默认批处理条数 + */ + private Integer defaultBatchSize = 20; + + /** + * 最大批处理条数 + */ + private Integer maxBatchSize = 50; + + /** + * 连接超时秒数 + */ + private Integer connectTimeoutSeconds = 10; + + /** + * 读取超时秒数 + */ + private Integer readTimeoutSeconds = 60; + + /** + * 单文件最大大小 + */ + private Long maxFileSizeBytes = 524288000L; + + /** + * HLS 最大分片数 + */ + private Integer maxHlsSegments = 500; + + /** + * HLS 最大总下载大小 + */ + private Long maxHlsTotalSizeBytes = 1073741824L; + + /** + * 采样错误条数上限 + */ + private Integer maxErrorSamples = 20; + + /** + * 播放列表最大大小 + */ + private Long maxPlaylistSizeBytes = 2097152L; + + /** + * 允许的域名白名单,留空则不限制 + */ + private List allowedHosts = new ArrayList<>(); + + public Duration taskExpireDuration() { + return Duration.ofHours(taskExpireHours == null ? 24L : taskExpireHours.longValue()); + } + + public boolean isHostAllowed(String host) { + if (host == null || host.isBlank()) { + return false; + } + if (allowedHosts == null || allowedHosts.isEmpty()) { + return true; + } + String normalizedHost = host.toLowerCase(Locale.ROOT); + for (String allowedHost : allowedHosts) { + if (allowedHost == null || allowedHost.isBlank()) { + continue; + } + String normalizedAllowed = allowedHost.toLowerCase(Locale.ROOT).trim(); + if (normalizedHost.equals(normalizedAllowed) || normalizedHost.endsWith("." + normalizedAllowed)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/controller/MediaResourceMaintenanceController.java b/src/main/java/com/hotwj/platform/system/maintenance/controller/MediaResourceMaintenanceController.java new file mode 100644 index 0000000..b56c195 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/controller/MediaResourceMaintenanceController.java @@ -0,0 +1,32 @@ +package com.hotwj.platform.system.maintenance.controller; + +import com.hotwj.platform.system.maintenance.domain.bo.MediaResourceMetadataRepairRequest; +import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairSubmitVo; +import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairTaskStatusVo; +import com.hotwj.platform.system.maintenance.service.IMediaResourceMetadataRepairService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.dromara.common.core.domain.R; +import org.dromara.common.web.core.BaseController; +import org.springframework.web.bind.annotation.*; + +/** + * 系统维护-媒体资源元数据修复 + */ +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/maintenance/media-resource") +public class MediaResourceMaintenanceController extends BaseController { + + private final IMediaResourceMetadataRepairService mediaResourceMetadataRepairService; + + @PostMapping("/repair-metadata") + public R repairMetadata(@Valid @RequestBody MediaResourceMetadataRepairRequest request) { + return R.ok(mediaResourceMetadataRepairService.submitRepairTask(request)); + } + + @GetMapping("/repair-metadata/{taskId}") + public R getTaskStatus(@PathVariable String taskId) { + return R.ok(mediaResourceMetadataRepairService.getTaskStatus(taskId)); + } +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/MediaResourceMetadataRepairRequest.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/MediaResourceMetadataRepairRequest.java new file mode 100644 index 0000000..8a52590 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/bo/MediaResourceMetadataRepairRequest.java @@ -0,0 +1,72 @@ +package com.hotwj.platform.system.maintenance.domain.bo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 媒体资源元数据修复请求 + */ +@Data +public class MediaResourceMetadataRepairRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 资源类型:1=视频 2=音频 + */ + private Long mediaType; + + /** + * 分类编码 + */ + private String categoryCode; + + /** + * 公司ID + */ + private Long companyId; + + /** + * 指定资源ID列表 + */ + private List resourceIds; + + /** + * 是否修复大小 + */ + private Boolean repairSize; + + /** + * 是否修复时长 + */ + private Boolean repairDuration; + + /** + * 是否覆盖已有有效值 + */ + private Boolean overwriteExisting; + + /** + * 是否只处理无效数据 + */ + private Boolean onlyInvalid; + + /** + * 单次处理上限 + */ + private Integer limit; + + /** + * 每批处理条数 + */ + private Integer batchSize; + + /** + * 是否跳过无法解析URL的记录 + */ + private Boolean skipUnresolvedUrl; +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/MediaResourceMetadataRepairSubmitVo.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/MediaResourceMetadataRepairSubmitVo.java new file mode 100644 index 0000000..36943e7 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/MediaResourceMetadataRepairSubmitVo.java @@ -0,0 +1,26 @@ +package com.hotwj.platform.system.maintenance.domain.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 提交媒体资源元数据修复任务响应 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MediaResourceMetadataRepairSubmitVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String taskId; + + private Boolean accepted; + + private String message; +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/MediaResourceMetadataRepairTaskStatusVo.java b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/MediaResourceMetadataRepairTaskStatusVo.java new file mode 100644 index 0000000..b13b8c7 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/domain/vo/MediaResourceMetadataRepairTaskStatusVo.java @@ -0,0 +1,63 @@ +package com.hotwj.platform.system.maintenance.domain.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 媒体资源元数据修复任务状态 + */ +@Data +public class MediaResourceMetadataRepairTaskStatusVo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String taskId; + + private String status; + + private Integer total = 0; + + private Integer processedCount = 0; + + private Integer successCount = 0; + + private Integer skipCount = 0; + + private Integer failCount = 0; + + private Long startTime; + + private Long endTime; + + private Long durationMillis; + + private String operatorName; + + private String requestSummary; + + private String message; + + private List updatedIds = new ArrayList<>(); + + private List errorSamples = new ArrayList<>(); + + @Data + public static class ErrorSample implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long resourceId; + + private String fileUrl; + + private String resolvedUrl; + + private String reason; + } +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/service/IMediaResourceMetadataRepairService.java b/src/main/java/com/hotwj/platform/system/maintenance/service/IMediaResourceMetadataRepairService.java new file mode 100644 index 0000000..d9c3461 --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/service/IMediaResourceMetadataRepairService.java @@ -0,0 +1,21 @@ +package com.hotwj.platform.system.maintenance.service; + +import com.hotwj.platform.system.maintenance.domain.bo.MediaResourceMetadataRepairRequest; +import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairSubmitVo; +import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairTaskStatusVo; + +/** + * 媒体资源元数据修复服务 + */ +public interface IMediaResourceMetadataRepairService { + + /** + * 提交修复任务 + */ + MediaResourceMetadataRepairSubmitVo submitRepairTask(MediaResourceMetadataRepairRequest request); + + /** + * 查询任务状态 + */ + MediaResourceMetadataRepairTaskStatusVo getTaskStatus(String taskId); +} diff --git a/src/main/java/com/hotwj/platform/system/maintenance/service/impl/MediaResourceMetadataRepairServiceImpl.java b/src/main/java/com/hotwj/platform/system/maintenance/service/impl/MediaResourceMetadataRepairServiceImpl.java new file mode 100644 index 0000000..1d58b3a --- /dev/null +++ b/src/main/java/com/hotwj/platform/system/maintenance/service/impl/MediaResourceMetadataRepairServiceImpl.java @@ -0,0 +1,785 @@ +package com.hotwj.platform.system.maintenance.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.hotwj.platform.config.mediaResource.domain.HotMediaResource; +import com.hotwj.platform.config.mediaResource.mapper.HotMediaResourceMapper; +import com.hotwj.platform.system.maintenance.config.MediaResourceMetadataRepairProperties; +import com.hotwj.platform.system.maintenance.domain.bo.MediaResourceMetadataRepairRequest; +import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairSubmitVo; +import com.hotwj.platform.system.maintenance.domain.vo.MediaResourceMetadataRepairTaskStatusVo; +import com.hotwj.platform.system.maintenance.service.IMediaResourceMetadataRepairService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.core.service.OssService; +import org.dromara.common.core.utils.StringUtils; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Serial; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 媒体资源元数据修复服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MediaResourceMetadataRepairServiceImpl implements IMediaResourceMetadataRepairService { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_RUNNING = "RUNNING"; + private static final String STATUS_SUCCESS = "SUCCESS"; + private static final String STATUS_PARTIAL_SUCCESS = "PARTIAL_SUCCESS"; + private static final String STATUS_FAILED = "FAILED"; + + private static final Pattern PURE_NUMBER = Pattern.compile("^\\d+$"); + private static final Pattern KEY_URI_PATTERN = Pattern.compile("URI=\"([^\"]+)\""); + private static final Pattern FFMPEG_DURATION_PATTERN = Pattern.compile("Duration: (\\d{2}):(\\d{2}):(\\d{2}(?:\\.\\d+)?)"); + + private final HotMediaResourceMapper hotMediaResourceMapper; + private final OssService ossService; + private final ScheduledExecutorService scheduledExecutorService; + private final MediaResourceMetadataRepairProperties properties; + + @Override + public MediaResourceMetadataRepairSubmitVo submitRepairTask(MediaResourceMetadataRepairRequest request) { + MediaResourceMetadataRepairRequest normalized = normalizeRequest(request); + String taskId = UUID.randomUUID().toString().replace("-", ""); + MediaResourceMetadataRepairTaskStatusVo taskStatus = new MediaResourceMetadataRepairTaskStatusVo(); + taskStatus.setTaskId(taskId); + taskStatus.setStatus(STATUS_PENDING); + taskStatus.setMessage("任务已提交,等待执行"); + taskStatus.setOperatorName(resolveOperatorName()); + taskStatus.setRequestSummary(buildRequestSummary(normalized)); + saveTask(taskStatus); + scheduledExecutorService.execute(() -> runTask(taskId, normalized)); + return new MediaResourceMetadataRepairSubmitVo(taskId, Boolean.TRUE, "媒体资源元数据修复任务已提交"); + } + + @Override + public MediaResourceMetadataRepairTaskStatusVo getTaskStatus(String taskId) { + MediaResourceMetadataRepairTaskStatusVo taskStatus = loadTask(taskId); + if (taskStatus == null) { + throw new ServiceException("任务不存在或已过期"); + } + return taskStatus; + } + + private void runTask(String taskId, MediaResourceMetadataRepairRequest request) { + MediaResourceMetadataRepairTaskStatusVo taskStatus = requireTask(taskId); + long taskStart = System.currentTimeMillis(); + Path taskWorkDir = getTaskWorkDir(taskId); + try { + taskStatus.setStatus(STATUS_RUNNING); + taskStatus.setStartTime(taskStart); + taskStatus.setMessage("任务执行中"); + long total = countMatchingResources(request); + taskStatus.setTotal((int) Math.min(total, request.getLimit())); + saveTask(taskStatus); + if (taskStatus.getTotal() <= 0) { + taskStatus.setStatus(STATUS_SUCCESS); + taskStatus.setEndTime(System.currentTimeMillis()); + taskStatus.setDurationMillis(taskStatus.getEndTime() - taskStart); + taskStatus.setMessage("未找到符合条件的媒体资源"); + saveTask(taskStatus); + return; + } + long lastId = 0L; + while (taskStatus.getProcessedCount() < taskStatus.getTotal()) { + int fetchSize = Math.min(request.getBatchSize(), taskStatus.getTotal() - taskStatus.getProcessedCount()); + List resources = fetchResources(request, lastId, fetchSize); + if (resources.isEmpty()) { + break; + } + for (HotMediaResource resource : resources) { + lastId = resource.getId(); + try { + ProcessResult result = processResource(taskId, request, resource); + if (result.type == ProcessResultType.SUCCESS) { + taskStatus.setSuccessCount(taskStatus.getSuccessCount() + 1); + taskStatus.getUpdatedIds().add(resource.getId()); + } else if (result.type == ProcessResultType.SKIP) { + taskStatus.setSkipCount(taskStatus.getSkipCount() + 1); + } else { + recordFailure(taskStatus, resource, result.resolvedUrl, result.message); + } + } catch (Exception ex) { + log.error("媒体资源元数据修复失败, resourceId={}", resource.getId(), ex); + recordFailure(taskStatus, resource, null, abbreviate(ex.getMessage(), 300)); + } finally { + taskStatus.setProcessedCount(taskStatus.getProcessedCount() + 1); + saveTask(taskStatus); + } + if (taskStatus.getProcessedCount() >= taskStatus.getTotal()) { + break; + } + } + } + taskStatus.setEndTime(System.currentTimeMillis()); + taskStatus.setDurationMillis(taskStatus.getEndTime() - taskStart); + taskStatus.setStatus(determineFinalStatus(taskStatus)); + taskStatus.setMessage(buildFinishMessage(taskStatus)); + saveTask(taskStatus); + } catch (Exception ex) { + log.error("媒体资源元数据修复任务执行失败, taskId={}", taskId, ex); + taskStatus.setStatus(STATUS_FAILED); + taskStatus.setEndTime(System.currentTimeMillis()); + taskStatus.setDurationMillis(taskStatus.getEndTime() - taskStart); + taskStatus.setMessage("任务执行失败: " + abbreviate(ex.getMessage(), 300)); + saveTask(taskStatus); + } finally { + deleteQuietly(taskWorkDir); + } + } + + private ProcessResult processResource(String taskId, MediaResourceMetadataRepairRequest request, HotMediaResource resource) throws IOException, InterruptedException { + ResolvedUrl resolvedUrl = resolveDownloadUrl(resource.getFileUrl()); + if (resolvedUrl == null || StringUtils.isBlank(resolvedUrl.url)) { + String reason = "未解析到可用下载地址"; + if (Boolean.TRUE.equals(request.getSkipUnresolvedUrl())) { + return ProcessResult.skip(reason, null); + } + return ProcessResult.fail(reason, null); + } + Path resourceDir = getResourceWorkDir(taskId, resource.getId()); + try { + Files.createDirectories(resourceDir); + DownloadedResource downloadedResource = resolvedUrl.hls + ? downloadHlsResource(resolvedUrl.url, resourceDir) + : downloadFileResource(resolvedUrl.url, resourceDir); + Long sizeBytes = downloadedResource.totalSizeBytes; + Long durationSeconds = probeDuration(downloadedResource.probePath, downloadedResource.extinfDuration); + HotMediaResource update = new HotMediaResource(); + update.setId(resource.getId()); + boolean hasUpdate = false; + if (Boolean.TRUE.equals(request.getRepairSize())) { + if (sizeBytes == null || sizeBytes <= 0) { + return ProcessResult.fail("未获取到有效资源大小", resolvedUrl.url); + } + if (Boolean.TRUE.equals(request.getOverwriteExisting()) || isInvalid(resource.getSizeBytes())) { + update.setSizeBytes(sizeBytes); + hasUpdate = true; + } + } + if (Boolean.TRUE.equals(request.getRepairDuration())) { + if (durationSeconds == null || durationSeconds <= 0) { + return ProcessResult.fail("未获取到有效媒体时长", resolvedUrl.url); + } + if (Boolean.TRUE.equals(request.getOverwriteExisting()) || isInvalid(resource.getDurationSeconds())) { + update.setDurationSeconds(durationSeconds); + hasUpdate = true; + } + } + if (!hasUpdate) { + return ProcessResult.skip("未命中可覆盖字段", resolvedUrl.url); + } + if (hotMediaResourceMapper.updateById(update) <= 0) { + return ProcessResult.fail("数据库更新失败", resolvedUrl.url); + } + return ProcessResult.success("修复成功", resolvedUrl.url); + } finally { + deleteQuietly(resourceDir); + } + } + + private ResolvedUrl resolveDownloadUrl(String fileUrl) { + String candidate = firstToken(fileUrl); + if (StringUtils.isBlank(candidate)) { + return null; + } + String resolvedUrl = candidate; + if (PURE_NUMBER.matcher(candidate).matches()) { + resolvedUrl = firstToken(ossService.selectUrlByIds(candidate)); + } + if (StringUtils.isBlank(resolvedUrl)) { + return null; + } + URI uri = toHttpUri(resolvedUrl); + if (!properties.isHostAllowed(uri.getHost())) { + throw new ServiceException("下载地址不在允许的域名白名单中: " + uri.getHost()); + } + String path = StringUtils.defaultString(uri.getPath()).toLowerCase(Locale.ROOT); + return new ResolvedUrl(uri.toString(), path.endsWith(".m3u8")); + } + + private DownloadedResource downloadFileResource(String url, Path resourceDir) throws IOException, InterruptedException { + URI uri = toHttpUri(url); + String extension = extensionFromPath(uri.getPath(), ".bin"); + Path targetFile = resourceDir.resolve("resource" + extension); + long size = downloadToFile(uri, targetFile, properties.getMaxFileSizeBytes()); + if (size <= 0) { + throw new ServiceException("下载文件大小为0"); + } + return new DownloadedResource(targetFile, size, null); + } + + private DownloadedResource downloadHlsResource(String url, Path resourceDir) throws IOException, InterruptedException { + HlsContext context = new HlsContext(); + HlsPlaylistResult playlistResult = localizePlaylist(toHttpUri(url), resourceDir.resolve("root.m3u8"), context); + if (context.totalSize <= 0) { + throw new ServiceException("HLS 下载结果为空"); + } + return new DownloadedResource(playlistResult.probePath, context.totalSize, playlistResult.extinfDuration); + } + + private HlsPlaylistResult localizePlaylist(URI playlistUri, Path localPlaylistPath, HlsContext context) throws IOException, InterruptedException { + byte[] contentBytes = downloadToBytes(playlistUri, properties.getMaxPlaylistSizeBytes()); + context.addBytes(contentBytes.length); + Files.createDirectories(localPlaylistPath.getParent()); + String content = new String(contentBytes, StandardCharsets.UTF_8); + if (content.contains("#EXT-X-STREAM-INF")) { + Files.write(localPlaylistPath, contentBytes); + URI variantUri = findFirstVariantUri(playlistUri, content); + if (variantUri == null) { + throw new ServiceException("未找到可用的 HLS 变体播放列表"); + } + return localizePlaylist(variantUri, localPlaylistPath.getParent().resolve("variant_0.m3u8"), context); + } + List outputLines = new ArrayList<>(); + long extinfDuration = 0L; + String[] lines = content.split("\\r?\\n", -1); + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.startsWith("#EXTINF:")) { + extinfDuration += parseExtinfDuration(trimmed); + outputLines.add(line); + continue; + } + if (trimmed.startsWith("#EXT-X-KEY") || trimmed.startsWith("#EXT-X-MAP")) { + outputLines.add(rewriteAttributeUriLine(playlistUri, line, localPlaylistPath.getParent(), context)); + continue; + } + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + outputLines.add(line); + continue; + } + URI resourceUri = playlistUri.resolve(trimmed); + String lowerPath = StringUtils.defaultString(resourceUri.getPath()).toLowerCase(Locale.ROOT); + if (lowerPath.endsWith(".m3u8")) { + String childName = "nested_" + context.nextFileIndex() + ".m3u8"; + localizePlaylist(resourceUri, localPlaylistPath.getParent().resolve(childName), context); + outputLines.add(childName); + } else { + String fileName = buildHlsAssetName(resourceUri, "segment_" + context.nextFileIndex()); + Path target = localPlaylistPath.getParent().resolve(fileName); + downloadToFile(resourceUri, target, properties.getMaxHlsTotalSizeBytes()); + context.addSegment(); + context.addBytes(Files.size(target)); + outputLines.add(fileName); + } + } + Files.writeString(localPlaylistPath, String.join(System.lineSeparator(), outputLines), StandardCharsets.UTF_8); + return new HlsPlaylistResult(localPlaylistPath, extinfDuration > 0 ? extinfDuration : null); + } + + private String rewriteAttributeUriLine(URI baseUri, String line, Path localDir, HlsContext context) throws IOException, InterruptedException { + Matcher matcher = KEY_URI_PATTERN.matcher(line); + if (!matcher.find()) { + return line; + } + URI assetUri = baseUri.resolve(matcher.group(1)); + String fileName = buildHlsAssetName(assetUri, "asset_" + context.nextFileIndex()); + Path target = localDir.resolve(fileName); + long fileSize = downloadToFile(assetUri, target, properties.getMaxHlsTotalSizeBytes()); + context.addBytes(fileSize); + return matcher.replaceFirst("URI=\"" + fileName.replace("\\", "/") + "\""); + } + + private URI findFirstVariantUri(URI playlistUri, String content) { + String[] lines = content.split("\\r?\\n"); + for (int i = 0; i < lines.length; i++) { + if (!lines[i].trim().startsWith("#EXT-X-STREAM-INF")) { + continue; + } + for (int j = i + 1; j < lines.length; j++) { + String candidate = lines[j].trim(); + if (candidate.isEmpty()) { + continue; + } + if (candidate.startsWith("#")) { + break; + } + return playlistUri.resolve(candidate); + } + } + return null; + } + + private Long probeDuration(Path probePath, Long extinfDuration) throws IOException, InterruptedException { + Long duration = probeByFfprobe(probePath); + if (duration != null && duration > 0) { + return duration; + } + duration = probeByFfmpeg(probePath); + if (duration != null && duration > 0) { + return duration; + } + return extinfDuration; + } + + private Long probeByFfprobe(Path probePath) throws IOException, InterruptedException { + List command = List.of( + properties.getFfprobeCommand(), + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + probePath.toAbsolutePath().toString() + ); + CommandResult commandResult = runCommand(command, Duration.ofSeconds(properties.getReadTimeoutSeconds())); + if (commandResult.exitCode != 0) { + return null; + } + String output = StringUtils.trim(commandResult.output); + if (StringUtils.isBlank(output)) { + return null; + } + try { + double seconds = Double.parseDouble(output); + long rounded = Math.round(seconds); + return rounded > 0 ? rounded : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private Long probeByFfmpeg(Path probePath) throws IOException, InterruptedException { + List command = List.of( + properties.getFfmpegCommand(), + "-i", + probePath.toAbsolutePath().toString() + ); + CommandResult commandResult = runCommand(command, Duration.ofSeconds(properties.getReadTimeoutSeconds())); + Matcher matcher = FFMPEG_DURATION_PATTERN.matcher(commandResult.output); + if (!matcher.find()) { + return null; + } + int hours = Integer.parseInt(matcher.group(1)); + int minutes = Integer.parseInt(matcher.group(2)); + double seconds = Double.parseDouble(matcher.group(3)); + long duration = Math.round(hours * 3600L + minutes * 60L + seconds); + return duration > 0 ? duration : null; + } + + private CommandResult runCommand(List command, Duration timeout) throws IOException, InterruptedException { + Process process = new ProcessBuilder(command) + .redirectErrorStream(true) + .start(); + boolean finished = process.waitFor(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS); + if (!finished) { + process.destroyForcibly(); + throw new ServiceException("媒体探测命令执行超时: " + command.get(0)); + } + String output; + try (InputStream inputStream = process.getInputStream()) { + output = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + return new CommandResult(process.exitValue(), output); + } + + private long countMatchingResources(MediaResourceMetadataRepairRequest request) { + return hotMediaResourceMapper.selectCount(buildBaseQuery(request)); + } + + private List fetchResources(MediaResourceMetadataRepairRequest request, long lastId, int fetchSize) { + LambdaQueryWrapper queryWrapper = buildBaseQuery(request); + queryWrapper.gt(lastId > 0, HotMediaResource::getId, lastId); + queryWrapper.orderByAsc(HotMediaResource::getId); + queryWrapper.last("limit " + fetchSize); + return hotMediaResourceMapper.selectList(queryWrapper); + } + + private LambdaQueryWrapper buildBaseQuery(MediaResourceMetadataRepairRequest request) { + LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery(); + queryWrapper.eq(request.getCompanyId() != null, HotMediaResource::getCompanyId, request.getCompanyId()); + queryWrapper.eq(request.getMediaType() != null, HotMediaResource::getMediaType, request.getMediaType()); + queryWrapper.eq(StringUtils.isNotBlank(request.getCategoryCode()), HotMediaResource::getCategoryCode, request.getCategoryCode()); + queryWrapper.in(request.getResourceIds() != null && !request.getResourceIds().isEmpty(), HotMediaResource::getId, request.getResourceIds()); + if (Boolean.TRUE.equals(request.getOnlyInvalid())) { + if (Boolean.TRUE.equals(request.getRepairSize()) && Boolean.TRUE.equals(request.getRepairDuration())) { + queryWrapper.and(wrapper -> wrapper + .nested(size -> size.isNull(HotMediaResource::getSizeBytes).or().le(HotMediaResource::getSizeBytes, 0)) + .or() + .nested(duration -> duration.isNull(HotMediaResource::getDurationSeconds).or().le(HotMediaResource::getDurationSeconds, 0)) + ); + } else if (Boolean.TRUE.equals(request.getRepairSize())) { + queryWrapper.and(wrapper -> wrapper.isNull(HotMediaResource::getSizeBytes).or().le(HotMediaResource::getSizeBytes, 0)); + } else if (Boolean.TRUE.equals(request.getRepairDuration())) { + queryWrapper.and(wrapper -> wrapper.isNull(HotMediaResource::getDurationSeconds).or().le(HotMediaResource::getDurationSeconds, 0)); + } + } + return queryWrapper; + } + + private MediaResourceMetadataRepairRequest normalizeRequest(MediaResourceMetadataRepairRequest request) { + if (request == null) { + throw new ServiceException("请求参数不能为空"); + } + request.setRepairSize(request.getRepairSize() == null ? Boolean.TRUE : request.getRepairSize()); + request.setRepairDuration(request.getRepairDuration() == null ? Boolean.TRUE : request.getRepairDuration()); + if (!Boolean.TRUE.equals(request.getRepairSize()) && !Boolean.TRUE.equals(request.getRepairDuration())) { + throw new ServiceException("至少需要开启一个修复项"); + } + request.setOverwriteExisting(request.getOverwriteExisting() == null ? Boolean.FALSE : request.getOverwriteExisting()); + request.setOnlyInvalid(request.getOnlyInvalid() == null ? Boolean.TRUE : request.getOnlyInvalid()); + request.setSkipUnresolvedUrl(request.getSkipUnresolvedUrl() == null ? Boolean.TRUE : request.getSkipUnresolvedUrl()); + int limit = request.getLimit() == null || request.getLimit() <= 0 ? properties.getDefaultLimit() : request.getLimit(); + request.setLimit(Math.min(limit, properties.getMaxLimit())); + int batchSize = request.getBatchSize() == null || request.getBatchSize() <= 0 ? properties.getDefaultBatchSize() : request.getBatchSize(); + request.setBatchSize(Math.min(batchSize, properties.getMaxBatchSize())); + return request; + } + + private void saveTask(MediaResourceMetadataRepairTaskStatusVo taskStatus) { + RedisUtils.setCacheObject(properties.getTaskKeyPrefix() + taskStatus.getTaskId(), taskStatus, properties.taskExpireDuration()); + } + + private MediaResourceMetadataRepairTaskStatusVo loadTask(String taskId) { + if (StringUtils.isBlank(taskId)) { + return null; + } + return RedisUtils.getCacheObject(properties.getTaskKeyPrefix() + taskId); + } + + private MediaResourceMetadataRepairTaskStatusVo requireTask(String taskId) { + MediaResourceMetadataRepairTaskStatusVo taskStatus = loadTask(taskId); + if (taskStatus == null) { + throw new ServiceException("任务不存在或已过期"); + } + return taskStatus; + } + + private void recordFailure(MediaResourceMetadataRepairTaskStatusVo taskStatus, HotMediaResource resource, String resolvedUrl, String reason) { + taskStatus.setFailCount(taskStatus.getFailCount() + 1); + if (taskStatus.getErrorSamples().size() >= properties.getMaxErrorSamples()) { + return; + } + MediaResourceMetadataRepairTaskStatusVo.ErrorSample errorSample = new MediaResourceMetadataRepairTaskStatusVo.ErrorSample(); + errorSample.setResourceId(resource.getId()); + errorSample.setFileUrl(resource.getFileUrl()); + errorSample.setResolvedUrl(resolvedUrl); + errorSample.setReason(reason); + taskStatus.getErrorSamples().add(errorSample); + } + + private String determineFinalStatus(MediaResourceMetadataRepairTaskStatusVo taskStatus) { + if (taskStatus.getFailCount() > 0 && taskStatus.getSuccessCount() > 0) { + return STATUS_PARTIAL_SUCCESS; + } + if (taskStatus.getFailCount() > 0 && taskStatus.getSuccessCount() <= 0) { + return STATUS_FAILED; + } + return STATUS_SUCCESS; + } + + private String buildFinishMessage(MediaResourceMetadataRepairTaskStatusVo taskStatus) { + return String.format( + Locale.ROOT, + "任务完成:成功 %d,跳过 %d,失败 %d", + taskStatus.getSuccessCount(), + taskStatus.getSkipCount(), + taskStatus.getFailCount() + ); + } + + private String buildRequestSummary(MediaResourceMetadataRepairRequest request) { + List parts = new ArrayList<>(); + parts.add("mediaType=" + (request.getMediaType() == null ? "ALL" : request.getMediaType())); + parts.add("categoryCode=" + StringUtils.defaultIfBlank(request.getCategoryCode(), "ALL")); + parts.add("companyId=" + (request.getCompanyId() == null ? "ALL" : request.getCompanyId())); + parts.add("repairSize=" + request.getRepairSize()); + parts.add("repairDuration=" + request.getRepairDuration()); + parts.add("overwriteExisting=" + request.getOverwriteExisting()); + parts.add("onlyInvalid=" + request.getOnlyInvalid()); + parts.add("limit=" + request.getLimit()); + parts.add("batchSize=" + request.getBatchSize()); + if (request.getResourceIds() != null && !request.getResourceIds().isEmpty()) { + parts.add("resourceIds=" + request.getResourceIds().size()); + } + return String.join(", ", parts); + } + + private String resolveOperatorName() { + try { + return LoginHelper.getUsername(); + } catch (Exception ignored) { + return "unknown"; + } + } + + private Path getTaskWorkDir(String taskId) { + return Path.of(properties.getWorkDir(), taskId); + } + + private Path getResourceWorkDir(String taskId, Long resourceId) { + return getTaskWorkDir(taskId).resolve(resourceId + "_" + System.currentTimeMillis()); + } + + private long downloadToFile(URI uri, Path targetFile, long maxSizeBytes) throws IOException, InterruptedException { + HttpResponse response = httpClient().send(buildRequest(uri), HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new ServiceException("下载失败, HTTP状态码: " + response.statusCode()); + } + Files.createDirectories(targetFile.getParent()); + long total = 0L; + try (InputStream inputStream = response.body()) { + byte[] buffer = new byte[8192]; + int read; + try (var outputStream = Files.newOutputStream(targetFile)) { + while ((read = inputStream.read(buffer)) != -1) { + total += read; + if (maxSizeBytes > 0 && total > maxSizeBytes) { + throw new ServiceException("下载文件超过大小限制"); + } + outputStream.write(buffer, 0, read); + } + } + } + return total; + } + + private byte[] downloadToBytes(URI uri, long maxSizeBytes) throws IOException, InterruptedException { + HttpResponse response = httpClient().send(buildRequest(uri), HttpResponse.BodyHandlers.ofInputStream()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new ServiceException("下载失败, HTTP状态码: " + response.statusCode()); + } + try (InputStream inputStream = response.body()) { + byte[] buffer = new byte[8192]; + int read; + int total = 0; + var outputStream = new java.io.ByteArrayOutputStream(); + while ((read = inputStream.read(buffer)) != -1) { + total += read; + if (maxSizeBytes > 0 && total > maxSizeBytes) { + throw new ServiceException("下载内容超过大小限制"); + } + outputStream.write(buffer, 0, read); + } + return outputStream.toByteArray(); + } + } + + private HttpRequest buildRequest(URI uri) { + return HttpRequest.newBuilder(uri) + .GET() + .timeout(Duration.ofSeconds(properties.getReadTimeoutSeconds())) + .header("User-Agent", "hotwj-media-resource-repair") + .build(); + } + + private HttpClient httpClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(properties.getConnectTimeoutSeconds())) + .build(); + } + + private URI toHttpUri(String url) { + try { + URI uri = new URI(StringUtils.trim(url)); + String scheme = StringUtils.defaultString(uri.getScheme()).toLowerCase(Locale.ROOT); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + throw new ServiceException("仅支持 http/https 下载地址"); + } + return uri; + } catch (URISyntaxException ex) { + throw new ServiceException("URL 不合法: " + abbreviate(url, 200)); + } + } + + private String firstToken(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String[] values = raw.split("[,\\r\\n]"); + for (String value : values) { + if (StringUtils.isNotBlank(value)) { + return value.trim(); + } + } + return null; + } + + private boolean isInvalid(Long value) { + return value == null || value <= 0; + } + + private String buildHlsAssetName(URI uri, String defaultName) { + return defaultName + extensionFromPath(uri.getPath(), ".bin"); + } + + private String extensionFromPath(String path, String defaultExtension) { + if (StringUtils.isBlank(path)) { + return defaultExtension; + } + int index = path.lastIndexOf('.'); + if (index < 0 || index == path.length() - 1) { + return defaultExtension; + } + String extension = path.substring(index); + return extension.length() > 12 ? defaultExtension : extension; + } + + private long parseExtinfDuration(String line) { + String value = line.substring("#EXTINF:".length()); + int commaIndex = value.indexOf(','); + if (commaIndex >= 0) { + value = value.substring(0, commaIndex); + } + try { + return Math.round(Double.parseDouble(value.trim())); + } catch (NumberFormatException ignored) { + return 0L; + } + } + + private String abbreviate(String value, int maxLength) { + if (value == null) { + return ""; + } + String normalized = value.replaceAll("\\s+", " ").trim(); + if (normalized.length() <= maxLength) { + return normalized; + } + return normalized.substring(0, Math.max(maxLength - 3, 0)) + "..."; + } + + private void deleteQuietly(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try { + if (Files.isDirectory(path)) { + try (DirectoryStream stream = Files.newDirectoryStream(path)) { + for (Path child : stream) { + deleteQuietly(child); + } + } + } + Files.deleteIfExists(path); + } catch (Exception ex) { + log.warn("删除临时文件失败: {}", path, ex); + } + } + + private enum ProcessResultType { + SUCCESS, + SKIP, + FAIL + } + + private static class ProcessResult { + private final ProcessResultType type; + private final String message; + private final String resolvedUrl; + + private ProcessResult(ProcessResultType type, String message, String resolvedUrl) { + this.type = type; + this.message = message; + this.resolvedUrl = resolvedUrl; + } + + private static ProcessResult success(String message, String resolvedUrl) { + return new ProcessResult(ProcessResultType.SUCCESS, message, resolvedUrl); + } + + private static ProcessResult skip(String message, String resolvedUrl) { + return new ProcessResult(ProcessResultType.SKIP, message, resolvedUrl); + } + + private static ProcessResult fail(String message, String resolvedUrl) { + return new ProcessResult(ProcessResultType.FAIL, message, resolvedUrl); + } + } + + private static class ResolvedUrl { + private final String url; + private final boolean hls; + + private ResolvedUrl(String url, boolean hls) { + this.url = url; + this.hls = hls; + } + } + + private static class DownloadedResource { + private final Path probePath; + private final Long totalSizeBytes; + private final Long extinfDuration; + + private DownloadedResource(Path probePath, Long totalSizeBytes, Long extinfDuration) { + this.probePath = probePath; + this.totalSizeBytes = totalSizeBytes; + this.extinfDuration = extinfDuration; + } + } + + private static class HlsPlaylistResult { + private final Path probePath; + private final Long extinfDuration; + + private HlsPlaylistResult(Path probePath, Long extinfDuration) { + this.probePath = probePath; + this.extinfDuration = extinfDuration; + } + } + + private static class CommandResult { + private final int exitCode; + private final String output; + + private CommandResult(int exitCode, String output) { + this.exitCode = exitCode; + this.output = output; + } + } + + private class HlsContext implements java.io.Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private long totalSize = 0L; + private int segmentCount = 0; + private int fileIndex = 0; + + private void addBytes(long size) { + totalSize += size; + if (properties.getMaxHlsTotalSizeBytes() != null && properties.getMaxHlsTotalSizeBytes() > 0 && totalSize > properties.getMaxHlsTotalSizeBytes()) { + throw new ServiceException("HLS 总下载大小超过限制"); + } + } + + private void addSegment() { + segmentCount++; + if (properties.getMaxHlsSegments() != null && properties.getMaxHlsSegments() > 0 && segmentCount > properties.getMaxHlsSegments()) { + throw new ServiceException("HLS 分片数超过限制"); + } + } + + private int nextFileIndex() { + fileIndex++; + return fileIndex; + } + } +}