feat(media): 新增缺失转码产物自动补齐
This commit is contained in:
1 parent
df2da25833
commit
40728e685b
10 files changed
+510
-2
No files matched your search
+13
@@ -15,6 +15,8 @@ import com.fruitshorts.media.vo.UploadCompleteReqVO;
|
|||||||
import com.fruitshorts.media.vo.UploadInitReqVO;
|
import com.fruitshorts.media.vo.UploadInitReqVO;
|
||||||
import com.fruitshorts.media.vo.UploadInitRespVO;
|
import com.fruitshorts.media.vo.UploadInitRespVO;
|
||||||
import com.fruitshorts.media.vo.UploadProgressRespVO;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -41,6 +43,7 @@ public class MediaController {
|
|||||||
|
|
||||||
private final MediaService mediaService;
|
private final MediaService mediaService;
|
||||||
private final MediaTranscodeProfileService profileService;
|
private final MediaTranscodeProfileService profileService;
|
||||||
|
private final TranscodeBackfillAdminService backfillAdminService;
|
||||||
|
|
||||||
@Operation(summary = "初始化分片上传")
|
@Operation(summary = "初始化分片上传")
|
||||||
@PostMapping("/upload/init")
|
@PostMapping("/upload/init")
|
||||||
@@ -110,4 +113,14 @@ public class MediaController {
|
|||||||
ImpersonationGuard.rejectWrite();
|
ImpersonationGuard.rejectWrite();
|
||||||
return Result.ok(profileService.save(profile));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+112
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -45,7 +45,9 @@ class MediaTranscodeProfileContractTest {
|
|||||||
objectMapper = new ObjectMapper();
|
objectMapper = new ObjectMapper();
|
||||||
MediaTranscodeProfileService profileService = new MediaTranscodeProfileService(
|
MediaTranscodeProfileService profileService = new MediaTranscodeProfileService(
|
||||||
configService, objectMapper, new TranscodeProperties());
|
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);
|
mockMvc = ControllerContractSupport.mockMvcOf(controller);
|
||||||
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
|
given(configService.get(eq(MediaTranscodeProfileService.CONFIG_KEY), anyString()))
|
||||||
.willReturn("");
|
.willReturn("");
|
||||||
|
|||||||
+3
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
package com.fruitshorts.admin.controller;
|
package com.fruitshorts.admin.controller;
|
||||||
|
|
||||||
|
import com.fruitshorts.admin.service.TranscodeBackfillAdminService;
|
||||||
import com.fruitshorts.admin.support.ControllerContractSupport;
|
import com.fruitshorts.admin.support.ControllerContractSupport;
|
||||||
import com.fruitshorts.common.exception.ErrorCode;
|
import com.fruitshorts.common.exception.ErrorCode;
|
||||||
import com.fruitshorts.common.exception.ServiceException;
|
import com.fruitshorts.common.exception.ServiceException;
|
||||||
@@ -32,7 +33,8 @@ class MediaTranscodeTriggerContractTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
mediaService = mock(MediaService.class);
|
mediaService = mock(MediaService.class);
|
||||||
MediaController controller = new MediaController(
|
MediaController controller = new MediaController(
|
||||||
mediaService, mock(MediaTranscodeProfileService.class));
|
mediaService, mock(MediaTranscodeProfileService.class),
|
||||||
|
mock(TranscodeBackfillAdminService.class));
|
||||||
mockMvc = ControllerContractSupport.mockMvcOf(controller);
|
mockMvc = ControllerContractSupport.mockMvcOf(controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
@@ -4,6 +4,46 @@ package com.fruitshorts.content.dal.mapper;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface DramaEpisodeMapper extends BaseMapper<DramaEpisode> {
|
public interface DramaEpisodeMapper extends BaseMapper<DramaEpisode> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询需要补转码的分集:有片源但没有可用码率产物,或缺少缩略图轴,且当前没有排队/执行中的任务。
|
||||||
|
*
|
||||||
|
* @param afterId 上一批处理到的分集 ID,用于游标轮转,避免固定前 N 条长期占用名额
|
||||||
|
* @param limit 单批上限
|
||||||
|
* @param requireFrames 是否把"缺缩略图"也视为缺失;关闭雪碧图时传 false
|
||||||
|
*/
|
||||||
|
@Select("""
|
||||||
|
<script>
|
||||||
|
SELECT e.id, e.drama_id, e.video_asset_id, e.video_url
|
||||||
|
FROM drama_episode e
|
||||||
|
WHERE e.deleted = 0
|
||||||
|
AND e.id > #{afterId}
|
||||||
|
AND (e.video_asset_id > 0
|
||||||
|
OR (e.video_url IS NOT NULL AND e.video_url <> ''))
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM transcode_task t
|
||||||
|
WHERE t.episode_id = e.id AND t.status IN ('QUEUED', 'RUNNING'))
|
||||||
|
AND (
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM media_rendition r
|
||||||
|
WHERE r.episode_id = e.id AND r.deleted = 0 AND r.status = 'ready')
|
||||||
|
<if test="requireFrames">
|
||||||
|
OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM media_thumbnail m
|
||||||
|
WHERE m.episode_id = e.id AND m.deleted = 0)
|
||||||
|
</if>
|
||||||
|
)
|
||||||
|
ORDER BY e.id ASC
|
||||||
|
LIMIT #{limit}
|
||||||
|
</script>
|
||||||
|
""")
|
||||||
|
List<DramaEpisode> selectTranscodeBackfillCandidates(@Param("afterId") long afterId,
|
||||||
|
@Param("limit") int limit,
|
||||||
|
@Param("requireFrames") boolean requireFrames);
|
||||||
}
|
}
|
||||||
+9
@@ -28,6 +28,15 @@ public class TranscodeProperties {
|
|||||||
/** 队列空时的轮询间隔(毫秒)。 */
|
/** 队列空时的轮询间隔(毫秒)。 */
|
||||||
private long pollDelayMs = 5000L;
|
private long pollDelayMs = 5000L;
|
||||||
|
|
||||||
|
/** 是否自动巡检缺失码率产物/缩略图的分集并补入队(仅 worker 进程生效)。 */
|
||||||
|
private boolean backfillEnabled = true;
|
||||||
|
|
||||||
|
/** 自动补队的巡检间隔(毫秒)。 */
|
||||||
|
private long backfillDelayMs = 300000L;
|
||||||
|
|
||||||
|
/** 单轮巡检最多补入队的分集数。 */
|
||||||
|
private int backfillBatchSize = 20;
|
||||||
|
|
||||||
/** ffmpeg 可执行文件(生产建议绝对路径或容器内固定路径)。 */
|
/** ffmpeg 可执行文件(生产建议绝对路径或容器内固定路径)。 */
|
||||||
private String ffmpegPath = "ffmpeg";
|
private String ffmpegPath = "ffmpeg";
|
||||||
|
|
||||||
|
|||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.worker;
|
||||||
|
|
||||||
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
|
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
||||||
|
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||||
|
import com.fruitshorts.media.config.TranscodeProperties;
|
||||||
|
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.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转码补齐巡检:定时找出"有片源但缺少码率产物或缩略图轴"的分集,自动补入转码队列。
|
||||||
|
*
|
||||||
|
* <p>与 worker 同进程、同开关(`worker-enabled`):在线入口不跑巡检,避免与播放链路抢资源。
|
||||||
|
* 已排队/执行中的分集不重复补队;最近一次任务已 FAILED 的分集不自动重试,
|
||||||
|
* 交给运营在后台处理,避免坏片源反复占用转码资源。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "fruitshorts.media.transcode.worker-enabled", havingValue = "true")
|
||||||
|
public class TranscodeBackfillScanner {
|
||||||
|
|
||||||
|
private static final int MIN_BATCH_SIZE = 1;
|
||||||
|
private static final int MAX_BATCH_SIZE = 100;
|
||||||
|
private static final long CURSOR_START = 0L;
|
||||||
|
|
||||||
|
private final DramaEpisodeMapper episodeMapper;
|
||||||
|
private final TranscodeService transcodeService;
|
||||||
|
private final MediaAssetMapper mediaAssetMapper;
|
||||||
|
private final MediaTranscodeOutputService outputService;
|
||||||
|
private final MediaTranscodeProfileService profileService;
|
||||||
|
private final TranscodeProperties properties;
|
||||||
|
|
||||||
|
/** 游标:记录上一批处理到的分集 ID,处理完一轮后回到起点。 */
|
||||||
|
private final AtomicLong cursor = new AtomicLong(CURSOR_START);
|
||||||
|
|
||||||
|
/** 只对缺少媒资的分集告警一次,避免每轮巡检刷日志。 */
|
||||||
|
private final Set<Long> loggedMissingAssetEpisodeIds = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${fruitshorts.media.transcode.backfill-delay-ms:300000}")
|
||||||
|
public void scan() {
|
||||||
|
if (!properties.isBackfillEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int triggered = scanOnce();
|
||||||
|
if (triggered > 0) {
|
||||||
|
log.info("转码补齐巡检:本轮补入 {} 个分集", triggered);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
// 巡检异常不能让调度线程终止,否则后续分集再也补不上
|
||||||
|
log.error("转码补齐巡检异常:{}", ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描一批分集并补队。
|
||||||
|
*
|
||||||
|
* @return 本轮实际入队的分集数
|
||||||
|
*/
|
||||||
|
public int scanOnce() {
|
||||||
|
int limit = batchSize();
|
||||||
|
List<DramaEpisode> candidates = episodeMapper.selectTranscodeBackfillCandidates(
|
||||||
|
cursor.get(), limit, framesRequired());
|
||||||
|
if (candidates == null || candidates.isEmpty()) {
|
||||||
|
cursor.set(CURSOR_START);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
cursor.set(candidates.size() < limit
|
||||||
|
? CURSOR_START : candidates.get(candidates.size() - 1).getId());
|
||||||
|
int triggered = 0;
|
||||||
|
for (DramaEpisode episode : candidates) {
|
||||||
|
if (triggerIfNeeded(episode)) {
|
||||||
|
triggered++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return triggered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean triggerIfNeeded(DramaEpisode episode) {
|
||||||
|
Long mediaAssetId = resolveMediaAssetId(episode);
|
||||||
|
if (mediaAssetId == null) {
|
||||||
|
warnMissingAsset(episode.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TranscodeTask latest = transcodeService.findLatestTaskByMediaAssetId(mediaAssetId);
|
||||||
|
if (latest != null && isInProgress(latest.getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (latest != null && TranscodeService.STATUS_FAILED.equals(latest.getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
MediaAsset asset = mediaAssetMapper.selectById(mediaAssetId);
|
||||||
|
if (asset == null) {
|
||||||
|
warnMissingAsset(episode.getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
TranscodeTriggerRespVO result = transcodeService.trigger(
|
||||||
|
mediaAssetId, asset.getSourceUrl(), episode.getDramaId(), episode.getId());
|
||||||
|
log.info("分集 {} 缺少码率产物或缩略图,已自动入队 task={}",
|
||||||
|
episode.getId(), result == null ? null : result.getTaskId());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 优先用分集已绑定的媒资,老数据没有绑定则按视频地址反查。 */
|
||||||
|
private Long resolveMediaAssetId(DramaEpisode episode) {
|
||||||
|
Long videoAssetId = episode.getVideoAssetId();
|
||||||
|
if (videoAssetId != null && videoAssetId > 0) {
|
||||||
|
return videoAssetId;
|
||||||
|
}
|
||||||
|
return outputService.resolveMediaAssetId(episode.getVideoUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isInProgress(String status) {
|
||||||
|
return TranscodeService.STATUS_QUEUED.equals(status)
|
||||||
|
|| TranscodeService.STATUS_RUNNING.equals(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭雪碧图时不会产出缩略图,此时只看码率产物,避免每轮误判重复转码。 */
|
||||||
|
private boolean framesRequired() {
|
||||||
|
MediaTranscodeProfile profile = profileService.currentProfile();
|
||||||
|
return profile != null && profile.getSprite() != null && profile.getSprite().isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int batchSize() {
|
||||||
|
return Math.max(MIN_BATCH_SIZE, Math.min(properties.getBackfillBatchSize(), MAX_BATCH_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void warnMissingAsset(Long episodeId) {
|
||||||
|
if (episodeId != null && loggedMissingAssetEpisodeIds.add(episodeId)) {
|
||||||
|
log.warn("分集 {} 找不到对应媒资,跳过自动转码", episodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ fruitshorts:
|
|||||||
# 只有本进程打开转码(§8.5 隔离要求)
|
# 只有本进程打开转码(§8.5 隔离要求)
|
||||||
worker-enabled: true
|
worker-enabled: true
|
||||||
poll-delay-ms: 3000
|
poll-delay-ms: 3000
|
||||||
|
# 自动巡检缺失码率产物/缩略图的分集并补入队
|
||||||
|
backfill-enabled: true
|
||||||
|
backfill-delay-ms: 300000
|
||||||
|
backfill-batch-size: 20
|
||||||
ffmpeg-path: ${FFMPEG_PATH:ffmpeg}
|
ffmpeg-path: ${FFMPEG_PATH:ffmpeg}
|
||||||
ffprobe-path: ${FFPROBE_PATH:ffprobe}
|
ffprobe-path: ${FFPROBE_PATH:ffprobe}
|
||||||
segment-seconds: 6
|
segment-seconds: 6
|
||||||
|
|||||||
+143
@@ -0,0 +1,143 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
package com.fruitshorts.worker;
|
||||||
|
|
||||||
|
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||||
|
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
||||||
|
import com.fruitshorts.media.config.MediaTranscodeProfile;
|
||||||
|
import com.fruitshorts.media.config.TranscodeProperties;
|
||||||
|
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 org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.BDDMockito.given;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缺失转码产物/缩略图的分集自动补队。
|
||||||
|
*/
|
||||||
|
class TranscodeBackfillScannerTest {
|
||||||
|
|
||||||
|
private static final long EPISODE_ID = 1L;
|
||||||
|
private static final long DRAMA_ID = 2L;
|
||||||
|
private static final long ASSET_ID = 9L;
|
||||||
|
private static final long RESOLVED_ASSET_ID = 77L;
|
||||||
|
private static final long TASK_ID = 55L;
|
||||||
|
private static final String SOURCE_URL = "/files/episode/video.mp4";
|
||||||
|
private static final String VIDEO_URL = "/files/episode/video.mp4";
|
||||||
|
|
||||||
|
private DramaEpisodeMapper episodeMapper;
|
||||||
|
private TranscodeService transcodeService;
|
||||||
|
private MediaAssetMapper mediaAssetMapper;
|
||||||
|
private MediaTranscodeOutputService outputService;
|
||||||
|
private MediaTranscodeProfileService profileService;
|
||||||
|
private TranscodeBackfillScanner scanner;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
episodeMapper = mock(DramaEpisodeMapper.class);
|
||||||
|
transcodeService = mock(TranscodeService.class);
|
||||||
|
mediaAssetMapper = mock(MediaAssetMapper.class);
|
||||||
|
outputService = mock(MediaTranscodeOutputService.class);
|
||||||
|
profileService = mock(MediaTranscodeProfileService.class);
|
||||||
|
TranscodeProperties properties = new TranscodeProperties();
|
||||||
|
given(profileService.currentProfile()).willReturn(new MediaTranscodeProfile());
|
||||||
|
scanner = new TranscodeBackfillScanner(episodeMapper, transcodeService,
|
||||||
|
mediaAssetMapper, outputService, profileService, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_enqueue_task_when_episode_missing_rendition() {
|
||||||
|
givenCandidates(episode(ASSET_ID));
|
||||||
|
given(transcodeService.findLatestTaskByMediaAssetId(ASSET_ID)).willReturn(null);
|
||||||
|
given(mediaAssetMapper.selectById(ASSET_ID)).willReturn(asset(ASSET_ID));
|
||||||
|
given(transcodeService.trigger(ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID))
|
||||||
|
.willReturn(new TranscodeTriggerRespVO(TASK_ID, TranscodeService.STATUS_QUEUED));
|
||||||
|
|
||||||
|
int triggered = scanner.scanOnce();
|
||||||
|
|
||||||
|
assertThat(triggered).isEqualTo(1);
|
||||||
|
verify(transcodeService).trigger(ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_resolve_asset_by_video_url_when_episode_has_no_asset_id() {
|
||||||
|
givenCandidates(episode(null));
|
||||||
|
given(outputService.resolveMediaAssetId(VIDEO_URL)).willReturn(RESOLVED_ASSET_ID);
|
||||||
|
given(transcodeService.findLatestTaskByMediaAssetId(RESOLVED_ASSET_ID)).willReturn(null);
|
||||||
|
given(mediaAssetMapper.selectById(RESOLVED_ASSET_ID)).willReturn(asset(RESOLVED_ASSET_ID));
|
||||||
|
given(transcodeService.trigger(RESOLVED_ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID))
|
||||||
|
.willReturn(new TranscodeTriggerRespVO(TASK_ID, TranscodeService.STATUS_QUEUED));
|
||||||
|
|
||||||
|
int triggered = scanner.scanOnce();
|
||||||
|
|
||||||
|
assertThat(triggered).isEqualTo(1);
|
||||||
|
verify(transcodeService).trigger(RESOLVED_ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_skip_when_latest_task_failed() {
|
||||||
|
givenCandidates(episode(ASSET_ID));
|
||||||
|
given(transcodeService.findLatestTaskByMediaAssetId(ASSET_ID))
|
||||||
|
.willReturn(task(TranscodeService.STATUS_FAILED));
|
||||||
|
|
||||||
|
int triggered = scanner.scanOnce();
|
||||||
|
|
||||||
|
assertThat(triggered).isZero();
|
||||||
|
verify(transcodeService, never()).trigger(anyLong(), any(), anyLong(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void should_skip_when_task_still_running() {
|
||||||
|
givenCandidates(episode(ASSET_ID));
|
||||||
|
given(transcodeService.findLatestTaskByMediaAssetId(ASSET_ID))
|
||||||
|
.willReturn(task(TranscodeService.STATUS_RUNNING));
|
||||||
|
|
||||||
|
int triggered = scanner.scanOnce();
|
||||||
|
|
||||||
|
assertThat(triggered).isZero();
|
||||||
|
verify(transcodeService, never()).trigger(anyLong(), any(), anyLong(), anyLong());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void givenCandidates(DramaEpisode episode) {
|
||||||
|
given(episodeMapper.selectTranscodeBackfillCandidates(anyLong(), anyInt(), anyBoolean()))
|
||||||
|
.willReturn(List.of(episode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DramaEpisode episode(Long videoAssetId) {
|
||||||
|
DramaEpisode episode = new DramaEpisode();
|
||||||
|
episode.setId(EPISODE_ID);
|
||||||
|
episode.setDramaId(DRAMA_ID);
|
||||||
|
episode.setVideoAssetId(videoAssetId);
|
||||||
|
episode.setVideoUrl(VIDEO_URL);
|
||||||
|
return episode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MediaAsset asset(Long id) {
|
||||||
|
MediaAsset asset = new MediaAsset();
|
||||||
|
asset.setId(id);
|
||||||
|
asset.setSourceUrl(SOURCE_URL);
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranscodeTask task(String status) {
|
||||||
|
TranscodeTask task = new TranscodeTask();
|
||||||
|
task.setStatus(status);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user