feat(media): 新增缺失转码产物自动补齐

This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-23 14:14:39 +08:00
1 parent df2da25833
commit 40728e685b
10 files changed
+510 -2

No files matched your search

@@ -15,6 +15,8 @@ import com.fruitshorts.media.vo.UploadCompleteReqVO;
import com.fruitshorts.media.vo.UploadInitReqVO;
import com.fruitshorts.media.vo.UploadInitRespVO;
import com.fruitshorts.media.vo.UploadProgressRespVO;
import com.fruitshorts.admin.service.TranscodeBackfillAdminService;
import com.fruitshorts.admin.vo.TranscodeBackfillRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -41,6 +43,7 @@ public class MediaController {
private final MediaService mediaService;
private final MediaTranscodeProfileService profileService;
private final TranscodeBackfillAdminService backfillAdminService;
@Operation(summary = "初始化分片上传")
@PostMapping("/upload/init")
@@ -110,4 +113,14 @@ public class MediaController {
ImpersonationGuard.rejectWrite();
return Result.ok(profileService.save(profile));
}
@Operation(summary = "手动补齐转码:扫描缺产物/帧图的分集并入队")
@PostMapping("/transcode/backfill")
@PreAuthorize("@ss.hasPermission('episode:upload')")
@OperationLog(module = "后台管理", action = "补齐转码")
public Result<TranscodeBackfillRespVO> backfill(
@RequestParam(value = "batchSize", required = false) Integer batchSize) {
ImpersonationGuard.rejectWrite();
return Result.ok(backfillAdminService.runOnce(batchSize));
}
}
@@ -0,0 +1,112 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.service;
import com.fruitshorts.admin.vo.TranscodeBackfillRespVO;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.config.MediaTranscodeProfile;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.entity.TranscodeTask;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.service.MediaTranscodeProfileService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 手动触发一轮转码补齐巡检:找出"有片源但缺少码率产物或缩略图"的分集并入队。
*
* <p>与 worker 的定时巡检(TranscodeBackfillScanner)共用同一条候选 SQL 与 trigger 语义;
* 区别在于手动触发允许重试最近一次 FAILED 的任务——运营点按钮就是要补齐,包括失败重跑。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TranscodeBackfillAdminService {
private static final int MIN_BATCH_SIZE = 1;
private static final int MAX_BATCH_SIZE = 100;
private static final int DEFAULT_BATCH_SIZE = 20;
private final DramaEpisodeMapper episodeMapper;
private final TranscodeService transcodeService;
private final MediaAssetMapper mediaAssetMapper;
private final MediaTranscodeOutputService outputService;
private final MediaTranscodeProfileService profileService;
/**
* 扫描一批缺产物的分集并入队。
*
* @param requestedBatchSize 单轮上限,超出范围时收敛到默认值
* @return 入队结果与明细
*/
public TranscodeBackfillRespVO runOnce(Integer requestedBatchSize) {
int batchSize = normalizeBatchSize(requestedBatchSize);
List<DramaEpisode> candidates = episodeMapper.selectTranscodeBackfillCandidates(
0L, batchSize, framesRequired());
List<TranscodeBackfillRespVO.Item> items = new ArrayList<>();
if (candidates == null || candidates.isEmpty()) {
return new TranscodeBackfillRespVO(0, items);
}
for (DramaEpisode episode : candidates) {
triggerIfNeeded(episode, items);
}
log.info("后台手动补齐转码:候选 {} 个,实际入队 {} 个",
candidates.size(), items.size());
return new TranscodeBackfillRespVO(items.size(), items);
}
private void triggerIfNeeded(DramaEpisode episode,
List<TranscodeBackfillRespVO.Item> items) {
Long mediaAssetId = resolveMediaAssetId(episode);
if (mediaAssetId == null) {
log.warn("分集 {} 找不到对应媒资,跳过补齐转码", episode.getId());
return;
}
TranscodeTask latest = transcodeService.findLatestTaskByMediaAssetId(mediaAssetId);
if (latest != null && (TranscodeService.STATUS_QUEUED.equals(latest.getStatus())
|| TranscodeService.STATUS_RUNNING.equals(latest.getStatus()))) {
return;
}
MediaAsset asset = mediaAssetMapper.selectById(mediaAssetId);
if (asset == null) {
log.warn("分集 {} 绑定的媒资 {} 不存在,跳过补齐转码",
episode.getId(), mediaAssetId);
return;
}
TranscodeTriggerRespVO result = transcodeService.trigger(
mediaAssetId, asset.getSourceUrl(), episode.getDramaId(), episode.getId());
items.add(new TranscodeBackfillRespVO.Item(
String.valueOf(episode.getId()),
result == null ? "" : String.valueOf(result.getTaskId())));
}
/** 优先用分集已绑定的媒资,老数据没有绑定则按视频地址反查。 */
private Long resolveMediaAssetId(DramaEpisode episode) {
Long videoAssetId = episode.getVideoAssetId();
if (videoAssetId != null && videoAssetId > 0) {
return videoAssetId;
}
return outputService.resolveMediaAssetId(episode.getVideoUrl());
}
/** 关闭雪碧图时不会产出缩略图,此时只看码率产物,避免误判重复转码。 */
private boolean framesRequired() {
MediaTranscodeProfile profile = profileService.currentProfile();
return profile != null && profile.getSprite() != null && profile.getSprite().isEnabled();
}
private int normalizeBatchSize(Integer requestedBatchSize) {
if (requestedBatchSize == null || requestedBatchSize <= 0) {
return DEFAULT_BATCH_SIZE;
}
return Math.min(requestedBatchSize, MAX_BATCH_SIZE);
}
}
@@ -0,0 +1,32 @@
// Author: fruit.com.cn
package com.fruitshorts.admin.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/** 一轮转码补齐巡检的结果。 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TranscodeBackfillRespVO {
/** 本轮实际补入队的分集数。 */
private int triggered;
/** 补入队的分集与任务明细。 */
private List<Item> items;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Item {
private String episodeId;
private String taskId;
}
}
@@ -45,7 +45,9 @@ class MediaTranscodeProfileContractTest {
objectMapper = new ObjectMapper();
MediaTranscodeProfileService profileService = new MediaTranscodeProfileService(
configService, objectMapper, new TranscodeProperties());
MediaController controller = new MediaController(mock(MediaService.class), profileService);
MediaController controller = new MediaController(
mock(MediaService.class), profileService,
mock(com.fruitshorts.admin.service.TranscodeBackfillAdminService.class));
mockMvc = ControllerContractSupport.mockMvcOf(controller);
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
.willReturn("");
@@ -2,6 +2,7 @@
package com.fruitshorts.admin.controller;
import com.fruitshorts.admin.service.TranscodeBackfillAdminService;
import com.fruitshorts.admin.support.ControllerContractSupport;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
@@ -32,7 +33,8 @@ class MediaTranscodeTriggerContractTest {
void setUp() {
mediaService = mock(MediaService.class);
MediaController controller = new MediaController(
mediaService, mock(MediaTranscodeProfileService.class));
mediaService, mock(MediaTranscodeProfileService.class),
mock(TranscodeBackfillAdminService.class));
mockMvc = ControllerContractSupport.mockMvcOf(controller);
}