From 090f149b31e2e184fff2865286d3ed84456611a9 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 14:10:15 +0800 Subject: [PATCH] =?UTF-8?q?test(media):=20=E8=A6=86=E7=9B=96=E8=BD=AC?= =?UTF-8?q?=E7=A0=81=E4=BA=A7=E7=89=A9=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-22-media-transcode-output-api.md | 28 +++ docs/plans/PROGRESS.md | 25 ++ .../DashboardTranscodeContractTest.java | 52 +++++ .../EpisodeTranscodeContractTest.java | 61 +++++ .../MediaTranscodeOutputContractTest.java | 90 ++++++++ .../EpisodeTranscodeAdminServiceTest.java | 54 +++++ fruitshorts-module-media/pom.xml | 5 + .../MediaTranscodeOutputServiceTest.java | 213 ++++++++++++++++++ .../media/service/ThumbnailVttParserTest.java | 44 ++++ .../service/TranscodeQueueStatsTest.java | 53 +++++ 10 files changed, 625 insertions(+) create mode 100644 docs/plans/2026-10-22-media-transcode-output-api.md create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DashboardTranscodeContractTest.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeTranscodeContractTest.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/MediaTranscodeOutputContractTest.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminServiceTest.java create mode 100644 fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/MediaTranscodeOutputServiceTest.java create mode 100644 fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/ThumbnailVttParserTest.java create mode 100644 fruitshorts-module-media/src/test/java/com/fruitshorts/media/service/TranscodeQueueStatsTest.java 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); + } +}