diff --git a/docs/plans/2026-10-22-media-transcode-output-api.md b/docs/plans/2026-10-22-media-transcode-output-api.md
new file mode 100644
index 0000000..66530ba
--- /dev/null
+++ b/docs/plans/2026-10-22-media-transcode-output-api.md
@@ -0,0 +1,28 @@
+# 转码产物查询接口
+
+## 目标
+
+- 媒体维度查询 rendition 与帧图产物。
+- 分集维度聚合转码任务、rendition、帧图状态。
+- `/admin/dashboard/transcode` 改为明确 VO,OpenAPI 不再出现匿名 Object。
+
+## 设计
+
+1. `MediaTranscodeOutputService` 统一读取媒体、任务、rendition 与 thumbnail。
+2. rendition 按高度、视频码率、质量倒序,id 正序保证稳定。
+3. thumbnail 优先读取 `thumbnails.vtt`,解析 cue 后分页;无记录返回 `EMPTY`,
+ 记录失败返回 `FAILED`,不把缺产物当服务错误。
+4. 分集聚合对不存在、无视频、无媒资、无任务返回明确 status 和空列表。
+5. dashboard 使用强类型 queue/failed task VO,保留现有字段语义并补 total。
+
+## TDD
+
+- 先提交媒体有/无产物、不存在、VTT 解析与分页边界测试。
+- 再提交接口、VO、聚合服务和 dashboard 类型收敛实现。
+- 门禁:`mvn -pl fruitshorts-module-media test`。
+
+## 红线
+
+- 不改 `/app` 契约、specs、ReelShortWeb、Flutter。
+- 不改 P1 入队与手动重转逻辑。
+- 不启停服务,不操作真机。
diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md
index 0065835..593f9bc 100644
--- a/docs/plans/PROGRESS.md
+++ b/docs/plans/PROGRESS.md
@@ -25314,3 +25314,28 @@ $ mvn -pl fruitshorts-admin-api -am test
后台控制器改动连同既有 admin 契约、权限、服务测试全部通过。
未启停服务、未执行数据库迁移、未操作真机。
+## 2026-10-22 转码产物查询接口 P2
+
+- 分支:`feature/media-transcode-output-api`
+- 基线:`develop` / `origin/develop` = `f66eae5`
+- 隔离工作树:`/private/tmp/fruitserver-media-output-api`
+- 计划:`docs/plans/2026-10-22-media-transcode-output-api.md`
+
+### TDD 红测
+
+先补媒体清晰度、帧图分页、VTT 解析、后台聚合与 dashboard 强类型契约测试,
+生产代码保持未实现。
+
+```text
+$ mvn -pl fruitshorts-module-media test
+[INFO] Compiling 3 source files with javac [debug release 17] to target/test-classes
+[ERROR] MediaTranscodeOutputServiceTest.java:[37,13] 找不到符号
+ 符号: 类 MediaTranscodeOutputService
+[INFO] fruitshorts-module-media ........................... FAILURE
+[INFO] BUILD FAILURE
+```
+
+解读:失败原因正是查询服务尚未实现。`fruitshorts-module-media` 新增
+`spring-boot-starter-test` test scope,使指定门禁能实际执行本次单元测试。
+下一步实现 VTT 解析、稳定排序、分页和 admin 聚合 VO 后转绿。
+
diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DashboardTranscodeContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DashboardTranscodeContractTest.java
new file mode 100644
index 0000000..aead6f3
--- /dev/null
+++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DashboardTranscodeContractTest.java
@@ -0,0 +1,52 @@
+// Author: fruit.com.cn
+
+package com.fruitshorts.admin.controller;
+
+import com.fruitshorts.admin.service.DashboardService;
+import com.fruitshorts.admin.support.ControllerContractSupport;
+import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
+import com.fruitshorts.media.vo.TranscodeQueueStatsVO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class DashboardTranscodeContractTest {
+
+ private DashboardService dashboardService;
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ dashboardService = mock(DashboardService.class);
+ mockMvc = ControllerContractSupport.mockMvcOf(
+ new DashboardAdminController(dashboardService));
+ }
+
+ @Test
+ void should_return_typed_queue_and_failed_tasks() throws Exception {
+ TranscodeQueueStatsVO queue = new TranscodeQueueStatsVO(
+ 10L, 2L, 1L, 5L, 1L, 1L, 20L);
+ TranscodeDashboardRespVO vo = new TranscodeDashboardRespVO();
+ vo.setQueue(queue);
+ vo.setFailedTasks(List.of());
+ vo.setNotes(List.of("queue total"));
+ given(dashboardService.transcode()).willReturn(vo);
+
+ mockMvc.perform(get("/admin/dashboard/transcode"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.queue.total").value(10))
+ .andExpect(jsonPath("$.data.queue.queued").value(2))
+ .andExpect(jsonPath("$.data.queue.running").value(1))
+ .andExpect(jsonPath("$.data.queue.success").value(5))
+ .andExpect(jsonPath("$.data.queue.failed").value(1))
+ .andExpect(jsonPath("$.data.failedTasks").isArray());
+ }
+}
diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeTranscodeContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeTranscodeContractTest.java
new file mode 100644
index 0000000..1efe399
--- /dev/null
+++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeTranscodeContractTest.java
@@ -0,0 +1,61 @@
+// Author: fruit.com.cn
+
+package com.fruitshorts.admin.controller;
+
+import com.fruitshorts.admin.service.EpisodeTranscodeAdminService;
+import com.fruitshorts.admin.support.ControllerContractSupport;
+import com.fruitshorts.admin.vo.EpisodeTranscodeRespVO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class EpisodeTranscodeContractTest {
+
+ private EpisodeTranscodeAdminService service;
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ service = mock(EpisodeTranscodeAdminService.class);
+ mockMvc = ControllerContractSupport.mockMvcOf(
+ new EpisodeTranscodeController(service));
+ }
+
+ @Test
+ void should_return_aggregated_episode_transcode_state() throws Exception {
+ EpisodeTranscodeRespVO vo = new EpisodeTranscodeRespVO();
+ vo.setEpisodeId(31L);
+ vo.setMediaAssetId(88L);
+ vo.setTaskId(8L);
+ vo.setStatus("SUCCESS");
+ vo.setProgress(100);
+ given(service.detail(31L)).willReturn(vo);
+
+ mockMvc.perform(get("/admin/episodes/31/transcode"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.episodeId").value("31"))
+ .andExpect(jsonPath("$.data.mediaAssetId").value("88"))
+ .andExpect(jsonPath("$.data.status").value("SUCCESS"))
+ .andExpect(jsonPath("$.data.progress").value(100));
+ }
+
+ @Test
+ void should_return_explicit_empty_state_when_episode_has_no_video() throws Exception {
+ EpisodeTranscodeRespVO vo = new EpisodeTranscodeRespVO();
+ vo.setEpisodeId(31L);
+ vo.setStatus("NO_VIDEO");
+ given(service.detail(31L)).willReturn(vo);
+
+ mockMvc.perform(get("/admin/episodes/31/transcode"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.status").value("NO_VIDEO"))
+ .andExpect(jsonPath("$.data.renditions").isEmpty());
+ }
+}
diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeOutputContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeOutputContractTest.java
new file mode 100644
index 0000000..f580d51
--- /dev/null
+++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeOutputContractTest.java
@@ -0,0 +1,90 @@
+// Author: fruit.com.cn
+
+package com.fruitshorts.admin.controller;
+
+import com.fruitshorts.admin.support.ControllerContractSupport;
+import com.fruitshorts.common.exception.ErrorCode;
+import com.fruitshorts.common.exception.ServiceException;
+import com.fruitshorts.common.pojo.PageResult;
+import com.fruitshorts.media.service.MediaTranscodeOutputService;
+import com.fruitshorts.media.vo.MediaRenditionRespVO;
+import com.fruitshorts.media.vo.MediaThumbnailRespVO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class MediaTranscodeOutputContractTest {
+
+ private MediaTranscodeOutputService outputService;
+ private MockMvc mockMvc;
+
+ @BeforeEach
+ void setUp() {
+ outputService = mock(MediaTranscodeOutputService.class);
+ mockMvc = ControllerContractSupport.mockMvcOf(
+ new MediaTranscodeOutputController(outputService));
+ }
+
+ @Test
+ void should_return_typed_rendition_list() throws Exception {
+ MediaRenditionRespVO vo = new MediaRenditionRespVO();
+ vo.setQuality("1080p");
+ vo.setQualityName("1080P");
+ vo.setResolution("1920x1080");
+ vo.setVideoBitrateKbps(5000);
+ vo.setVideoCodec("H.264");
+ vo.setAudioCodec("AAC");
+ vo.setContainer("HLS");
+ vo.setHlsUrl("/files/master.m3u8");
+ vo.setSizeBytes(4096L);
+ vo.setStatus("ready");
+ given(outputService.renditions(88L)).willReturn(List.of(vo));
+
+ mockMvc.perform(get("/admin/media/88/renditions"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data[0].quality").value("1080p"))
+ .andExpect(jsonPath("$.data[0].resolution").value("1920x1080"))
+ .andExpect(jsonPath("$.data[0].videoBitrateKbps").value(5000))
+ .andExpect(jsonPath("$.data[0].hlsUrl").value("/files/master.m3u8"));
+ }
+
+ @Test
+ void should_return_not_found_for_missing_media() throws Exception {
+ given(outputService.renditions(404L))
+ .willThrow(new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在"));
+
+ mockMvc.perform(get("/admin/media/404/renditions"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode()))
+ .andExpect(jsonPath("$.message").value("媒资不存在"));
+ }
+
+ @Test
+ void should_return_empty_thumbnail_status_without_error() throws Exception {
+ MediaThumbnailRespVO vo = new MediaThumbnailRespVO();
+ vo.setStatus("EMPTY");
+ vo.setPageIndex(1);
+ vo.setPageSize(100);
+ vo.setFrames(PageResult.empty());
+ given(outputService.thumbnails(88L, 1, 100)).willReturn(vo);
+
+ mockMvc.perform(get("/admin/media/88/thumbnails"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.status").value("EMPTY"))
+ .andExpect(jsonPath("$.data.frames.total").value(0))
+ .andExpect(jsonPath("$.data.frames.list").isEmpty());
+
+ verify(outputService).thumbnails(88L, 1, 100);
+ }
+}
diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminServiceTest.java
new file mode 100644
index 0000000..bb8339d
--- /dev/null
+++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminServiceTest.java
@@ -0,0 +1,54 @@
+// Author: fruit.com.cn
+
+package com.fruitshorts.admin.service;
+
+import com.fruitshorts.content.dal.entity.DramaEpisode;
+import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
+import com.fruitshorts.media.service.MediaTranscodeOutputService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+class EpisodeTranscodeAdminServiceTest {
+
+ private DramaEpisodeMapper episodeMapper;
+ private MediaTranscodeOutputService outputService;
+ private EpisodeTranscodeAdminService service;
+
+ @BeforeEach
+ void setUp() {
+ episodeMapper = mock(DramaEpisodeMapper.class);
+ outputService = mock(MediaTranscodeOutputService.class);
+ service = new EpisodeTranscodeAdminService(episodeMapper, outputService);
+ }
+
+ @Test
+ void should_return_episode_not_found_state_without_aggregating_media() {
+ given(episodeMapper.selectById(404L)).willReturn(null);
+
+ var result = service.detail(404L);
+
+ assertThat(result.getStatus()).isEqualTo("EPISODE_NOT_FOUND");
+ assertThat(result.getEpisodeId()).isEqualTo(404L);
+ assertThat(result.getRenditions()).isEmpty();
+ verifyNoInteractions(outputService);
+ }
+
+ @Test
+ void should_return_no_video_state_when_episode_video_url_is_blank() {
+ DramaEpisode episode = new DramaEpisode();
+ episode.setId(31L);
+ episode.setVideoUrl("");
+ given(episodeMapper.selectById(31L)).willReturn(episode);
+
+ var result = service.detail(31L);
+
+ assertThat(result.getStatus()).isEqualTo("NO_VIDEO");
+ assertThat(result.getRenditions()).isEmpty();
+ verifyNoInteractions(outputService);
+ }
+}
diff --git a/fruitshorts-module-media/pom.xml b/fruitshorts-module-media/pom.xml
index 756f5e0..e429c1a 100644
--- a/fruitshorts-module-media/pom.xml
+++ b/fruitshorts-module-media/pom.xml
@@ -25,5 +25,10 @@
com.fruitshorts
fruitshorts-module-system
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
diff --git a/fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/MediaTranscodeOutputServiceTest.java b/fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/MediaTranscodeOutputServiceTest.java
new file mode 100644
index 0000000..3741c8e
--- /dev/null
+++ b/fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/MediaTranscodeOutputServiceTest.java
@@ -0,0 +1,213 @@
+// Author: fruit.com.cn
+
+package com.fruitshorts.media.service;
+
+import com.fruitshorts.common.exception.ServiceException;
+import com.fruitshorts.media.dal.entity.MediaAsset;
+import com.fruitshorts.media.dal.entity.MediaRendition;
+import com.fruitshorts.media.dal.entity.MediaThumbnail;
+import com.fruitshorts.media.dal.entity.TranscodeTask;
+import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+class MediaTranscodeOutputServiceTest {
+
+ private static final Long MEDIA_ASSET_ID = 88L;
+ private static final Long EPISODE_ID = 31L;
+
+ @TempDir
+ Path tempDir;
+
+ private MediaAssetMapper mediaAssetMapper;
+ private TranscodeService transcodeService;
+ private MediaStorageService storageService;
+ private PlaybackSigner signer;
+ private MediaTranscodeOutputService service;
+
+ @BeforeEach
+ void setUp() {
+ mediaAssetMapper = mock(MediaAssetMapper.class);
+ transcodeService = mock(TranscodeService.class);
+ storageService = mock(MediaStorageService.class);
+ signer = mock(PlaybackSigner.class);
+ service = new MediaTranscodeOutputService(
+ mediaAssetMapper, transcodeService, storageService, signer);
+ given(signer.sign(anyString())).willAnswer(invocation -> invocation.getArgument(0));
+ }
+
+ @Test
+ void should_return_renditions_sorted_from_highest_to_lowest() {
+ givenMedia();
+ given(transcodeService.listRenditionsByMediaAssetId(MEDIA_ASSET_ID))
+ .willReturn(List.of(
+ rendition(1L, "360p", 640, 360, 400),
+ rendition(2L, "1080p", 1920, 1080, 5000),
+ rendition(3L, "720p", 1280, 720, 2500)));
+
+ var result = service.renditions(MEDIA_ASSET_ID);
+
+ assertThat(result).extracting(vo -> vo.getQuality())
+ .containsExactly("1080p", "720p", "360p");
+ assertThat(result.get(0).getQualityName()).isEqualTo("1080P");
+ assertThat(result.get(0).getResolution()).isEqualTo("1920x1080");
+ assertThat(result.get(0).getVideoBitrateKbps()).isEqualTo(5000);
+ assertThat(result.get(0).getVideoCodec()).isEqualTo("H.264");
+ assertThat(result.get(0).getAudioCodec()).isEqualTo("AAC");
+ assertThat(result.get(0).getContainer()).isEqualTo("HLS");
+ assertThat(result.get(0).getHlsUrl()).isEqualTo("/files/master.m3u8");
+ assertThat(result.get(0).getSizeBytes()).isEqualTo(4096L);
+ assertThat(result.get(0).getStatus()).isEqualTo("ready");
+ }
+
+ @Test
+ void should_return_not_found_when_media_does_not_exist() {
+ given(mediaAssetMapper.selectById(404L)).willReturn(null);
+
+ assertThatThrownBy(() -> service.renditions(404L))
+ .isInstanceOf(ServiceException.class)
+ .hasMessage("媒资不存在");
+ }
+
+ @Test
+ void should_return_empty_thumbnail_status_when_product_is_missing() {
+ givenMedia();
+ given(transcodeService.findLatestTaskByMediaAssetId(MEDIA_ASSET_ID))
+ .willReturn(task(EPISODE_ID));
+ given(transcodeService.findThumbnailByEpisodeId(EPISODE_ID)).willReturn(null);
+
+ var result = service.thumbnails(MEDIA_ASSET_ID, 1, 100);
+
+ assertThat(result.getStatus()).isEqualTo("EMPTY");
+ assertThat(result.getFrames().getTotal()).isZero();
+ assertThat(result.getFrames().getList()).isEmpty();
+ assertThat(result.getSprite()).isNull();
+ }
+
+ @Test
+ void should_page_vtt_frames_and_expose_sprite_metadata() throws Exception {
+ givenMedia();
+ given(transcodeService.findLatestTaskByMediaAssetId(MEDIA_ASSET_ID))
+ .willReturn(task(EPISODE_ID));
+ MediaThumbnail thumbnail = thumbnail();
+ given(transcodeService.findThumbnailByEpisodeId(EPISODE_ID)).willReturn(thumbnail);
+ writeVtt("""
+ WEBVTT
+
+ 00:00:00.000 --> 00:00:01.000
+ sprite_001.jpg#xywh=0,0,160,90
+
+ 00:00:01.000 --> 00:00:02.000
+ sprite_001.jpg#xywh=160,0,160,90
+
+ 00:00:02.000 --> 00:00:03.000
+ sprite_001.jpg#xywh=320,0,160,90
+
+ 00:00:03.000 --> 00:00:04.000
+ sprite_002.jpg#xywh=0,0,160,90
+ """);
+ given(storageService.fileFor("transcoded/8/thumbnails/thumbnails.vtt"))
+ .willReturn(tempDir.resolve("thumbnails.vtt"));
+ given(storageService.toPublicUrl("transcoded/8/thumbnails/thumbnails.vtt"))
+ .willReturn("/files/transcoded/8/thumbnails/thumbnails.vtt");
+ given(storageService.toPublicUrl("transcoded/8/thumbnails/sprite_001.jpg"))
+ .willReturn("/files/transcoded/8/thumbnails/sprite_001.jpg");
+ given(storageService.toPublicUrl("transcoded/8/thumbnails/sprite_002.jpg"))
+ .willReturn("/files/transcoded/8/thumbnails/sprite_002.jpg");
+
+ var result = service.thumbnails(MEDIA_ASSET_ID, 2, 2);
+
+ assertThat(result.getStatus()).isEqualTo("READY");
+ assertThat(result.getPageIndex()).isEqualTo(2);
+ assertThat(result.getPageSize()).isEqualTo(2);
+ assertThat(result.getFrames().getTotal()).isEqualTo(4);
+ assertThat(result.getFrames().getList()).hasSize(2);
+ assertThat(result.getFrames().getList().get(0).getFrameIndex()).isEqualTo(2);
+ assertThat(result.getFrames().getList().get(0).getTimeMs()).isEqualTo(2000);
+ assertThat(result.getFrames().getList().get(0).getImageUrl())
+ .isEqualTo("/files/transcoded/8/thumbnails/sprite_001.jpg#xywh=320,0,160,90");
+ assertThat(result.getSprite().getColumns()).isEqualTo(2);
+ assertThat(result.getSprite().getRows()).isEqualTo(2);
+ assertThat(result.getSprite().getTotalFrames()).isEqualTo(4);
+ assertThat(result.getSprite().getThumbWidth()).isEqualTo(160);
+ assertThat(result.getSprite().getThumbHeight()).isEqualTo(90);
+ assertThat(result.getSprite().getSpriteUrls()).hasSize(2);
+ assertThat(result.getSprite().getVttUrl())
+ .isEqualTo("/files/transcoded/8/thumbnails/thumbnails.vtt");
+ }
+
+ @Test
+ void should_clamp_thumbnail_page_size_to_five_hundred() {
+ givenMedia();
+ given(transcodeService.findLatestTaskByMediaAssetId(MEDIA_ASSET_ID))
+ .willReturn(task(EPISODE_ID));
+ given(transcodeService.findThumbnailByEpisodeId(EPISODE_ID))
+ .willReturn(thumbnail());
+
+ var result = service.thumbnails(MEDIA_ASSET_ID, 1, 501);
+
+ assertThat(result.getPageIndex()).isEqualTo(1);
+ assertThat(result.getPageSize()).isEqualTo(500);
+ }
+
+ private void givenMedia() {
+ MediaAsset asset = new MediaAsset();
+ asset.setId(MEDIA_ASSET_ID);
+ asset.setFileName("video.mp4");
+ given(mediaAssetMapper.selectById(MEDIA_ASSET_ID)).willReturn(asset);
+ }
+
+ private MediaRendition rendition(Long id, String quality, int width, int height, int bitrate) {
+ MediaRendition rendition = new MediaRendition();
+ rendition.setId(id);
+ rendition.setQuality(quality);
+ rendition.setWidth(width);
+ rendition.setHeight(height);
+ rendition.setVideoBitrate(bitrate);
+ rendition.setAudioBitrate(64);
+ rendition.setFormat("HLS");
+ rendition.setPlayUrl("/files/master.m3u8");
+ rendition.setSizeBytes(4096L);
+ rendition.setStatus("ready");
+ return rendition;
+ }
+
+ private TranscodeTask task(Long episodeId) {
+ TranscodeTask task = new TranscodeTask();
+ task.setId(8L);
+ task.setMediaAssetId(MEDIA_ASSET_ID);
+ task.setEpisodeId(episodeId);
+ task.setStatus(TranscodeService.STATUS_SUCCESS);
+ return task;
+ }
+
+ private MediaThumbnail thumbnail() {
+ MediaThumbnail thumbnail = new MediaThumbnail();
+ thumbnail.setEpisodeId(EPISODE_ID);
+ thumbnail.setVttObjectKey("transcoded/8/thumbnails/thumbnails.vtt");
+ thumbnail.setSpritePrefix("transcoded/8/thumbnails/sprite_");
+ thumbnail.setColumns(2);
+ thumbnail.setRows(2);
+ thumbnail.setThumbWidth(160);
+ thumbnail.setThumbHeight(90);
+ thumbnail.setFrameCount(4);
+ thumbnail.setIntervalSec(1);
+ thumbnail.setStatus(MediaThumbnail.STATUS_READY);
+ return thumbnail;
+ }
+
+ private void writeVtt(String content) throws Exception {
+ Files.writeString(tempDir.resolve("thumbnails.vtt"), content);
+ }
+}
diff --git a/fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/ThumbnailVttParserTest.java b/fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/ThumbnailVttParserTest.java
new file mode 100644
index 0000000..1c3bb0f
--- /dev/null
+++ b/fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/ThumbnailVttParserTest.java
@@ -0,0 +1,44 @@
+// Author: fruit.com.cn
+
+package com.fruitshorts.media.service;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ThumbnailVttParserTest {
+
+ @Test
+ void should_parse_timestamps_and_sprite_rectangles() {
+ String vtt = """
+ WEBVTT
+
+ 00:00:00.000 --> 00:00:01.000
+ sprite_001.jpg#xywh=0,0,160,90
+
+ 00:00:01.500 --> 00:00:02.500
+ sprite_002.jpg#xywh=160,90,160,90
+ """;
+
+ List cues = ThumbnailVttParser.parse(vtt);
+
+ assertThat(cues).hasSize(2);
+ assertThat(cues.get(0).startMs()).isZero();
+ assertThat(cues.get(0).endMs()).isEqualTo(1000);
+ assertThat(cues.get(0).spriteFile()).isEqualTo("sprite_001.jpg");
+ assertThat(cues.get(1).startMs()).isEqualTo(1500);
+ assertThat(cues.get(1).spriteFile()).isEqualTo("sprite_002.jpg");
+ assertThat(cues.get(1).x()).isEqualTo(160);
+ assertThat(cues.get(1).y()).isEqualTo(90);
+ assertThat(cues.get(1).width()).isEqualTo(160);
+ assertThat(cues.get(1).height()).isEqualTo(90);
+ }
+
+ @Test
+ void should_return_empty_list_when_vtt_is_invalid() {
+ assertThat(ThumbnailVttParser.parse("not a vtt")).isEmpty();
+ assertThat(ThumbnailVttParser.parse(null)).isEmpty();
+ }
+}
diff --git a/fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/TranscodeQueueStatsTest.java b/fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/TranscodeQueueStatsTest.java
new file mode 100644
index 0000000..3f0e2cd
--- /dev/null
+++ b/fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/TranscodeQueueStatsTest.java
@@ -0,0 +1,53 @@
+// Author: fruit.com.cn
+
+package com.fruitshorts.media.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.fruitshorts.media.dal.entity.MediaRendition;
+import com.fruitshorts.media.dal.entity.MediaThumbnail;
+import com.fruitshorts.media.dal.entity.TranscodeTask;
+import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
+import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
+import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+class TranscodeQueueStatsTest {
+
+ @BeforeAll
+ static void initTableInfoCache() {
+ MapperBuilderAssistant assistant =
+ new MapperBuilderAssistant(new MybatisConfiguration(), "");
+ TableInfoHelper.initTableInfo(assistant, TranscodeTask.class);
+ TableInfoHelper.initTableInfo(assistant, MediaRendition.class);
+ TableInfoHelper.initTableInfo(assistant, MediaThumbnail.class);
+ }
+
+ @Test
+ void should_return_typed_total_and_status_counts() {
+ TranscodeTaskMapper taskMapper = mock(TranscodeTaskMapper.class);
+ MediaRenditionMapper renditionMapper = mock(MediaRenditionMapper.class);
+ given(taskMapper.selectCount(any())).willReturn(10L, 2L, 1L, 5L, 1L, 1L);
+ given(renditionMapper.selectCount(null)).willReturn(20L);
+ TranscodeService service = new TranscodeService(
+ taskMapper, renditionMapper, mock(MediaThumbnailMapper.class),
+ mock(ThumbnailRecordService.class));
+
+ var stats = service.stats();
+
+ assertThat(stats.getTotal()).isEqualTo(10L);
+ assertThat(stats.getQueued()).isEqualTo(2L);
+ assertThat(stats.getRunning()).isEqualTo(1L);
+ assertThat(stats.getSuccess()).isEqualTo(5L);
+ assertThat(stats.getFailed()).isEqualTo(1L);
+ assertThat(stats.getCanceled()).isEqualTo(1L);
+ assertThat(stats.getRenditions()).isEqualTo(20L);
+ }
+}