feat(system-maintenance): 新增媒体资源元数据修复功能
实现完整的媒体资源元数据修复业务模块,提供提交修复任务与查询任务状态的REST接口,支持修复媒体资源的大小和时长信息,适配普通文件和HLS流媒体,内置域名白名单、下载限制及任务缓存清理能力。
This commit is contained in:
@@ -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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MediaResourceMetadataRepairSubmitVo> repairMetadata(@Valid @RequestBody MediaResourceMetadataRepairRequest request) {
|
||||||
|
return R.ok(mediaResourceMetadataRepairService.submitRepairTask(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/repair-metadata/{taskId}")
|
||||||
|
public R<MediaResourceMetadataRepairTaskStatusVo> getTaskStatus(@PathVariable String taskId) {
|
||||||
|
return R.ok(mediaResourceMetadataRepairService.getTaskStatus(taskId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long> resourceIds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否修复大小
|
||||||
|
*/
|
||||||
|
private Boolean repairSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否修复时长
|
||||||
|
*/
|
||||||
|
private Boolean repairDuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否覆盖已有有效值
|
||||||
|
*/
|
||||||
|
private Boolean overwriteExisting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否只处理无效数据
|
||||||
|
*/
|
||||||
|
private Boolean onlyInvalid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次处理上限
|
||||||
|
*/
|
||||||
|
private Integer limit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每批处理条数
|
||||||
|
*/
|
||||||
|
private Integer batchSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否跳过无法解析URL的记录
|
||||||
|
*/
|
||||||
|
private Boolean skipUnresolvedUrl;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Long> updatedIds = new ArrayList<>();
|
||||||
|
|
||||||
|
private List<ErrorSample> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<HotMediaResource> 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<String> 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<String> 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<String> 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<String> 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<HotMediaResource> fetchResources(MediaResourceMetadataRepairRequest request, long lastId, int fetchSize) {
|
||||||
|
LambdaQueryWrapper<HotMediaResource> queryWrapper = buildBaseQuery(request);
|
||||||
|
queryWrapper.gt(lastId > 0, HotMediaResource::getId, lastId);
|
||||||
|
queryWrapper.orderByAsc(HotMediaResource::getId);
|
||||||
|
queryWrapper.last("limit " + fetchSize);
|
||||||
|
return hotMediaResourceMapper.selectList(queryWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LambdaQueryWrapper<HotMediaResource> buildBaseQuery(MediaResourceMetadataRepairRequest request) {
|
||||||
|
LambdaQueryWrapper<HotMediaResource> 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<String> 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<InputStream> 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<InputStream> 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<Path> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user