From 40728e685b203317c163bddb874566eae8550474 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 14:14:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(media):=20=E6=96=B0=E5=A2=9E=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E8=BD=AC=E7=A0=81=E4=BA=A7=E7=89=A9=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/MediaController.java | 13 ++ .../TranscodeBackfillAdminService.java | 112 +++++++++++++ .../admin/vo/TranscodeBackfillRespVO.java | 32 ++++ .../MediaTranscodeProfileContractTest.java | 4 +- .../MediaTranscodeTriggerContractTest.java | 4 +- .../dal/mapper/DramaEpisodeMapper.java | 40 +++++ .../media/config/TranscodeProperties.java | 9 ++ .../worker/TranscodeBackfillScanner.java | 151 ++++++++++++++++++ .../src/main/resources/application.yml | 4 + .../worker/TranscodeBackfillScannerTest.java | 143 +++++++++++++++++ 10 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/TranscodeBackfillAdminService.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeBackfillRespVO.java create mode 100644 fruitshorts-transcode-worker/src/main/java/com/fruitshorts/worker/TranscodeBackfillScanner.java create mode 100644 fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeBackfillScannerTest.java diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java index e8781db..8ae0bdf 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java @@ -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 backfill( + @RequestParam(value = "batchSize", required = false) Integer batchSize) { + ImpersonationGuard.rejectWrite(); + return Result.ok(backfillAdminService.runOnce(batchSize)); + } } diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/TranscodeBackfillAdminService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/TranscodeBackfillAdminService.java new file mode 100644 index 0000000..76071ad --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/TranscodeBackfillAdminService.java @@ -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; + +/** + * 手动触发一轮转码补齐巡检:找出"有片源但缺少码率产物或缩略图"的分集并入队。 + * + *

与 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 candidates = episodeMapper.selectTranscodeBackfillCandidates( + 0L, batchSize, framesRequired()); + List 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 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); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeBackfillRespVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeBackfillRespVO.java new file mode 100644 index 0000000..2b942d6 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeBackfillRespVO.java @@ -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 items; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Item { + + private String episodeId; + + private String taskId; + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeProfileContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeProfileContractTest.java index 4bb33a1..4c3438f 100644 --- a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeProfileContractTest.java +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeProfileContractTest.java @@ -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(""); diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeTriggerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeTriggerContractTest.java index a955f98..44a97c0 100644 --- a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeTriggerContractTest.java +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeTriggerContractTest.java @@ -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); } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaEpisodeMapper.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaEpisodeMapper.java index f71b485..fc54443 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaEpisodeMapper.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaEpisodeMapper.java @@ -4,6 +4,46 @@ package com.fruitshorts.content.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; 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 { + + /** + * 查询需要补转码的分集:有片源但没有可用码率产物,或缺少缩略图轴,且当前没有排队/执行中的任务。 + * + * @param afterId 上一批处理到的分集 ID,用于游标轮转,避免固定前 N 条长期占用名额 + * @param limit 单批上限 + * @param requireFrames 是否把"缺缩略图"也视为缺失;关闭雪碧图时传 false + */ + @Select(""" + + """) + List selectTranscodeBackfillCandidates(@Param("afterId") long afterId, + @Param("limit") int limit, + @Param("requireFrames") boolean requireFrames); } diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java index c81b17b..1805c01 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java @@ -28,6 +28,15 @@ public class TranscodeProperties { /** 队列空时的轮询间隔(毫秒)。 */ private long pollDelayMs = 5000L; + /** 是否自动巡检缺失码率产物/缩略图的分集并补入队(仅 worker 进程生效)。 */ + private boolean backfillEnabled = true; + + /** 自动补队的巡检间隔(毫秒)。 */ + private long backfillDelayMs = 300000L; + + /** 单轮巡检最多补入队的分集数。 */ + private int backfillBatchSize = 20; + /** ffmpeg 可执行文件(生产建议绝对路径或容器内固定路径)。 */ private String ffmpegPath = "ffmpeg"; diff --git a/fruitshorts-transcode-worker/src/main/java/com/fruitshorts/worker/TranscodeBackfillScanner.java b/fruitshorts-transcode-worker/src/main/java/com/fruitshorts/worker/TranscodeBackfillScanner.java new file mode 100644 index 0000000..9e2f540 --- /dev/null +++ b/fruitshorts-transcode-worker/src/main/java/com/fruitshorts/worker/TranscodeBackfillScanner.java @@ -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; + +/** + * 转码补齐巡检:定时找出"有片源但缺少码率产物或缩略图轴"的分集,自动补入转码队列。 + * + *

与 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 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 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); + } + } +} diff --git a/fruitshorts-transcode-worker/src/main/resources/application.yml b/fruitshorts-transcode-worker/src/main/resources/application.yml index 08a69d7..56f3977 100644 --- a/fruitshorts-transcode-worker/src/main/resources/application.yml +++ b/fruitshorts-transcode-worker/src/main/resources/application.yml @@ -42,6 +42,10 @@ fruitshorts: # 只有本进程打开转码(§8.5 隔离要求) worker-enabled: true poll-delay-ms: 3000 + # 自动巡检缺失码率产物/缩略图的分集并补入队 + backfill-enabled: true + backfill-delay-ms: 300000 + backfill-batch-size: 20 ffmpeg-path: ${FFMPEG_PATH:ffmpeg} ffprobe-path: ${FFPROBE_PATH:ffprobe} segment-seconds: 6 diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeBackfillScannerTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeBackfillScannerTest.java new file mode 100644 index 0000000..5c77b7d --- /dev/null +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeBackfillScannerTest.java @@ -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; + } +}