test(media): 覆盖转码产物查询接口
This commit is contained in:
1 parent
f66eae5baf
commit
090f149b31
10 files changed
+625
No files matched your search
@@ -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 入队与手动重转逻辑。
|
||||
- 不启停服务,不操作真机。
|
||||
@@ -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 后转绿。
|
||||
|
||||
+52
@@ -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());
|
||||
}
|
||||
}
|
||||
+61
@@ -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());
|
||||
}
|
||||
}
|
||||
+90
@@ -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);
|
||||
}
|
||||
}
|
||||
+54
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -25,5 +25,10 @@
|
||||
<groupId>com.fruitshorts</groupId>
|
||||
<artifactId>fruitshorts-module-system</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+213
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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<ThumbnailVttParser.Cue> 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();
|
||||
}
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user