merge: 补齐转码产物查询接口
This commit is contained in:
commit
40a884cc85
26 files changed
+1512
-31
No files matched your search
@@ -0,0 +1,38 @@
|
||||
# 转码产物查询接口
|
||||
|
||||
## 目标
|
||||
|
||||
- 媒体维度查询 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。
|
||||
|
||||
## 接口
|
||||
|
||||
- `GET /admin/media/{id}/renditions`
|
||||
- `GET /admin/media/{id}/thumbnails?pageIndex=1&pageSize=100`
|
||||
- `GET /admin/episodes/{id}/transcode`
|
||||
- `GET /admin/dashboard/transcode`(响应类型由 Map 收敛为 VO)
|
||||
|
||||
缩略图响应内嵌 `PageResult<MediaThumbnailFrameVO>`,同时返回 sprite/VTT 元数据;
|
||||
空产物 status 为 `EMPTY`,无绑定分集为 `NO_EPISODE`,损坏 VTT 为 `INVALID`。
|
||||
|
||||
## TDD
|
||||
|
||||
- 先提交媒体有/无产物、不存在、VTT 解析与分页边界测试。
|
||||
- 再提交接口、VO、聚合服务和 dashboard 类型收敛实现。
|
||||
- 门禁:`mvn -pl fruitshorts-module-media test`。
|
||||
|
||||
## 红线
|
||||
|
||||
- 不改 `/app` 契约、specs、ReelShortWeb、Flutter。
|
||||
- 不改 P1 入队与手动重转逻辑。
|
||||
- 不启停服务,不操作真机。
|
||||
@@ -25314,3 +25314,79 @@ $ 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 后转绿。
|
||||
|
||||
### 实现
|
||||
|
||||
- 新增 `MediaTranscodeOutputService`:媒体存在性校验、rendition 稳定倒序、
|
||||
VTT 读取与 cue 分页、签名后的 sprite/VTT/HLS 地址。
|
||||
- 新增 `ThumbnailVttParser`:解析多 sprite 文件、毫秒时间与 xywh 矩形。
|
||||
- `GET /admin/media/{id}/renditions` 返回强类型清晰度列表;媒体不存在使用
|
||||
`DATA_NOT_FOUND`。
|
||||
- `GET /admin/media/{id}/thumbnails` 默认 `pageIndex=1/pageSize=100`,
|
||||
`pageSize` 上限 500;无记录返回 `EMPTY`,不抛错。
|
||||
- `GET /admin/episodes/{id}/transcode` 聚合任务、rendition 与缩略图;
|
||||
分集不存在、无视频、无媒资、无任务分别返回明确 status。
|
||||
- `/admin/dashboard/transcode` 改为 `TranscodeDashboardRespVO`,queue 增加 total;
|
||||
failedTasks 也使用明确任务 VO。
|
||||
- 新响应 VO 均带 `@Schema`,Controller 使用 `Result<T>` / `PageResult<T>`,
|
||||
新接口不再返回 Map/Object。
|
||||
|
||||
### 测试
|
||||
|
||||
```text
|
||||
$ mvn -pl fruitshorts-module-media test
|
||||
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
```
|
||||
|
||||
补充 admin-api 与依赖模块全量测试:
|
||||
|
||||
```text
|
||||
$ mvn -pl fruitshorts-admin-api -am test
|
||||
[INFO] Tests run: 51, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] fruitshorts-admin-api .............................. SUCCESS
|
||||
[INFO] BUILD SUCCESS
|
||||
```
|
||||
|
||||
解读:清晰度排序、VTT 多 sprite 解析、帧图分页与 500 上限、空产物状态、
|
||||
媒体不存在、分集不存在/无视频、dashboard 强类型响应均通过。
|
||||
最终指定门禁再次运行仍为 8/8 通过。
|
||||
未启停服务、未执行数据库变更、未修改 `/app`、Web、Flutter、specs 或 API 文档。
|
||||
|
||||
补跑全仓回归:
|
||||
|
||||
```text
|
||||
$ mvn test
|
||||
[INFO] fruitshorts-admin-api .............................. SUCCESS
|
||||
[INFO] fruitshorts-app-api ................................ SUCCESS
|
||||
[INFO] fruitshorts-transcode-worker ....................... SUCCESS
|
||||
[INFO] BUILD SUCCESS
|
||||
```
|
||||
|
||||
解读:admin 51、app 109、worker 32 个测试均通过,P1 入队/重转与 worker
|
||||
链路未回归。
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.admin.service.DashboardService;
|
||||
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -40,7 +41,7 @@ public class DashboardAdminController {
|
||||
@Operation(summary = "转码看板(队列积压与失败任务)")
|
||||
@GetMapping("/transcode")
|
||||
@PreAuthorize("@ss.hasPermission('dashboard:view')")
|
||||
public Result<Map<String, Object>> transcode() {
|
||||
public Result<TranscodeDashboardRespVO> transcode() {
|
||||
return Result.ok(dashboardService.transcode());
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.admin.service.EpisodeTranscodeAdminService;
|
||||
import com.fruitshorts.admin.vo.EpisodeTranscodeRespVO;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** 后台剧集转码聚合查询。 */
|
||||
@Tag(name = "后台-剧集转码")
|
||||
@RestController
|
||||
@RequestMapping("/admin/episodes")
|
||||
@RequiredArgsConstructor
|
||||
public class EpisodeTranscodeController {
|
||||
|
||||
private final EpisodeTranscodeAdminService service;
|
||||
|
||||
@Operation(summary = "查询分集转码聚合信息")
|
||||
@GetMapping("/{id}/transcode")
|
||||
@PreAuthorize("@ss.hasPermission('episode:list')")
|
||||
public Result<EpisodeTranscodeRespVO> detail(@PathVariable Long id) {
|
||||
return Result.ok(service.detail(id));
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.media.service.MediaTranscodeOutputService;
|
||||
import com.fruitshorts.media.vo.MediaRenditionRespVO;
|
||||
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 后台媒资转码产物只读查询。 */
|
||||
@Tag(name = "后台-媒资转码产物")
|
||||
@RestController
|
||||
@RequestMapping("/admin/media")
|
||||
@RequiredArgsConstructor
|
||||
public class MediaTranscodeOutputController {
|
||||
|
||||
private final MediaTranscodeOutputService outputService;
|
||||
|
||||
@Operation(summary = "查询媒资清晰度产物")
|
||||
@GetMapping("/{id}/renditions")
|
||||
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
||||
public Result<List<MediaRenditionRespVO>> renditions(@PathVariable Long id) {
|
||||
return Result.ok(outputService.renditions(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "分页查询媒资帧图产物")
|
||||
@GetMapping("/{id}/thumbnails")
|
||||
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
||||
public Result<MediaThumbnailRespVO> thumbnails(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "1") Integer pageIndex,
|
||||
@RequestParam(defaultValue = "100") Integer pageSize) {
|
||||
return Result.ok(outputService.thumbnails(id, pageIndex, pageSize));
|
||||
}
|
||||
}
|
||||
+17
-12
@@ -3,6 +3,7 @@
|
||||
package com.fruitshorts.admin.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.admin.vo.TranscodeDashboardRespVO;
|
||||
import com.fruitshorts.commerce.dal.entity.CoinLedger;
|
||||
import com.fruitshorts.commerce.dal.entity.Entitlement;
|
||||
import com.fruitshorts.commerce.dal.entity.Orders;
|
||||
@@ -21,6 +22,8 @@ import com.fruitshorts.interaction.dal.entity.ContentReport;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentReportMapper;
|
||||
import com.fruitshorts.media.service.TranscodeService;
|
||||
import com.fruitshorts.media.vo.TranscodeQueueStatsVO;
|
||||
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
|
||||
import com.fruitshorts.playback.dal.entity.WatchHistory;
|
||||
import com.fruitshorts.playback.dal.entity.WatchProgress;
|
||||
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
|
||||
@@ -101,12 +104,14 @@ public class DashboardService {
|
||||
}
|
||||
|
||||
/** 转码看板:队列积压与失败明细。 */
|
||||
public Map<String, Object> transcode() {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("generatedAt", LocalDateTime.now());
|
||||
data.put("queue", transcodeService.stats());
|
||||
data.put("failedTasks", transcodeService.failedTasks(20));
|
||||
data.put("notes", List.of(
|
||||
public TranscodeDashboardRespVO transcode() {
|
||||
TranscodeDashboardRespVO data = new TranscodeDashboardRespVO();
|
||||
data.setGeneratedAt(LocalDateTime.now());
|
||||
data.setQueue(transcodeService.stats());
|
||||
data.setFailedTasks(transcodeService.failedTasks(20).stream()
|
||||
.map(TranscodeTaskRespVO::from)
|
||||
.toList());
|
||||
data.setNotes(List.of(
|
||||
"queue 为累计口径(不限时间),反映当前积压与历史成败分布",
|
||||
"failedTasks 最多返回 20 条,按 id 倒序"));
|
||||
return data;
|
||||
@@ -205,12 +210,12 @@ public class DashboardService {
|
||||
|
||||
private Map<String, Object> costMetrics(LocalDateTime sevenDaysAgo) {
|
||||
Map<String, Object> cost = new LinkedHashMap<>();
|
||||
Map<String, Object> queue = transcodeService.stats();
|
||||
cost.put("transcodeQueued", queue.get("queued"));
|
||||
cost.put("transcodeRunning", queue.get("running"));
|
||||
cost.put("transcodeFailed", queue.get("failed"));
|
||||
cost.put("transcodeSuccess", queue.get("success"));
|
||||
cost.put("renditions", queue.get("renditions"));
|
||||
TranscodeQueueStatsVO queue = transcodeService.stats();
|
||||
cost.put("transcodeQueued", queue.getQueued());
|
||||
cost.put("transcodeRunning", queue.getRunning());
|
||||
cost.put("transcodeFailed", queue.getFailed());
|
||||
cost.put("transcodeSuccess", queue.getSuccess());
|
||||
cost.put("renditions", queue.getRenditions());
|
||||
cost.put("otpSent7d", appUserOtpMapper.selectCount(Wrappers.<AppUserOtp>lambdaQuery()
|
||||
.ge(AppUserOtp::getCreatedAt, sevenDaysAgo)));
|
||||
cost.put("cdnTrafficBytes", null);
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.admin.service;
|
||||
|
||||
import com.fruitshorts.admin.vo.EpisodeTranscodeRespVO;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
|
||||
import com.fruitshorts.media.service.MediaTranscodeOutputService;
|
||||
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
|
||||
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** 聚合剧集与媒资转码产物,供后台编辑页一次读取。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EpisodeTranscodeAdminService {
|
||||
|
||||
private static final String STATUS_EPISODE_NOT_FOUND = "EPISODE_NOT_FOUND";
|
||||
private static final String STATUS_NO_VIDEO = "NO_VIDEO";
|
||||
private static final String STATUS_NO_MEDIA_ASSET = "NO_MEDIA_ASSET";
|
||||
private static final String STATUS_NO_TASK = "NO_TASK";
|
||||
private static final int THUMBNAIL_PAGE_INDEX = 1;
|
||||
private static final int THUMBNAIL_PAGE_SIZE = 100;
|
||||
|
||||
private final DramaEpisodeMapper episodeMapper;
|
||||
private final MediaTranscodeOutputService outputService;
|
||||
|
||||
public EpisodeTranscodeRespVO detail(Long episodeId) {
|
||||
DramaEpisode episode = episodeId == null ? null : episodeMapper.selectById(episodeId);
|
||||
if (episode == null) {
|
||||
return empty(episodeId, STATUS_EPISODE_NOT_FOUND);
|
||||
}
|
||||
boolean hasVideoAsset = episode.getVideoAssetId() != null
|
||||
&& episode.getVideoAssetId() > 0;
|
||||
if (!StringUtils.hasText(episode.getVideoUrl()) && !hasVideoAsset) {
|
||||
return empty(episodeId, STATUS_NO_VIDEO);
|
||||
}
|
||||
Long mediaAssetId = hasVideoAsset
|
||||
? episode.getVideoAssetId() : outputService.resolveMediaAssetId(episode.getVideoUrl());
|
||||
if (mediaAssetId == null) {
|
||||
return empty(episodeId, STATUS_NO_MEDIA_ASSET);
|
||||
}
|
||||
|
||||
EpisodeTranscodeRespVO result = new EpisodeTranscodeRespVO();
|
||||
result.setEpisodeId(episodeId);
|
||||
result.setMediaAssetId(mediaAssetId);
|
||||
result.setRenditions(outputService.renditionsForMediaAssetId(mediaAssetId));
|
||||
result.setThumbnails(outputService.thumbnailForEpisode(
|
||||
episodeId, mediaAssetId, THUMBNAIL_PAGE_INDEX, THUMBNAIL_PAGE_SIZE));
|
||||
|
||||
TranscodeTaskRespVO task = outputService.latestTask(mediaAssetId);
|
||||
if (task == null) {
|
||||
result.setStatus(taskStatusOf(episode));
|
||||
result.setProgress(0);
|
||||
result.setRetryCount(0);
|
||||
result.setErrorMsg("");
|
||||
return result;
|
||||
}
|
||||
result.setTaskId(task.getId());
|
||||
result.setStatus(task.getStatus());
|
||||
result.setProgress(task.getProgress());
|
||||
result.setRetryCount(task.getRetryCount());
|
||||
result.setErrorMsg(task.getErrorMsg());
|
||||
return result;
|
||||
}
|
||||
|
||||
private EpisodeTranscodeRespVO empty(Long episodeId, String status) {
|
||||
EpisodeTranscodeRespVO result = new EpisodeTranscodeRespVO();
|
||||
result.setEpisodeId(episodeId);
|
||||
result.setStatus(status);
|
||||
result.setProgress(0);
|
||||
result.setRetryCount(0);
|
||||
result.setErrorMsg("");
|
||||
result.setRenditions(List.of());
|
||||
result.setThumbnails(MediaThumbnailRespVO.empty(
|
||||
null, episodeId, MediaThumbnailRespVO.STATUS_EMPTY,
|
||||
THUMBNAIL_PAGE_INDEX, THUMBNAIL_PAGE_SIZE));
|
||||
return result;
|
||||
}
|
||||
|
||||
private String taskStatusOf(DramaEpisode episode) {
|
||||
return StringUtils.hasText(episode.getTranscodeStatus())
|
||||
? episode.getTranscodeStatus().toUpperCase(Locale.ROOT)
|
||||
: STATUS_NO_TASK;
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.admin.vo;
|
||||
|
||||
import com.fruitshorts.media.vo.MediaRenditionRespVO;
|
||||
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 后台剧集编辑页一次性获取的转码状态。 */
|
||||
@Data
|
||||
@Schema(description = "分集转码聚合信息")
|
||||
public class EpisodeTranscodeRespVO {
|
||||
|
||||
private Long episodeId;
|
||||
private Long mediaAssetId;
|
||||
private Long taskId;
|
||||
private String status;
|
||||
private Integer progress;
|
||||
private Integer retryCount;
|
||||
private String errorMsg;
|
||||
private List<MediaRenditionRespVO> renditions = List.of();
|
||||
private MediaThumbnailRespVO thumbnails;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.admin.vo;
|
||||
|
||||
import com.fruitshorts.media.vo.TranscodeQueueStatsVO;
|
||||
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 后台转码看板强类型响应。 */
|
||||
@Data
|
||||
@Schema(description = "转码看板")
|
||||
public class TranscodeDashboardRespVO {
|
||||
|
||||
private LocalDateTime generatedAt;
|
||||
private TranscodeQueueStatsVO queue;
|
||||
private List<TranscodeTaskRespVO> failedTasks = List.of();
|
||||
private List<String> notes = List.of();
|
||||
}
|
||||
+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>
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
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 com.fruitshorts.media.vo.MediaRenditionRespVO;
|
||||
import com.fruitshorts.media.vo.MediaThumbnailFrameVO;
|
||||
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
|
||||
import com.fruitshorts.media.vo.MediaThumbnailSpriteVO;
|
||||
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 后台只读查询转码产物;不参与 worker 入队、认领与回写。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MediaTranscodeOutputService {
|
||||
|
||||
private static final int DEFAULT_PAGE_INDEX = 1;
|
||||
private static final int DEFAULT_PAGE_SIZE = 100;
|
||||
private static final int MAX_PAGE_SIZE = 500;
|
||||
private static final Comparator<MediaRendition> RENDITION_ORDER =
|
||||
Comparator.comparing(MediaRendition::getHeight,
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(MediaRendition::getVideoBitrate,
|
||||
Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(MediaRendition::getId,
|
||||
Comparator.nullsLast(Comparator.naturalOrder()));
|
||||
|
||||
private final MediaAssetMapper mediaAssetMapper;
|
||||
private final TranscodeService transcodeService;
|
||||
private final MediaStorageService storageService;
|
||||
private final PlaybackSigner signer;
|
||||
|
||||
public List<MediaRenditionRespVO> renditions(Long mediaAssetId) {
|
||||
requireMedia(mediaAssetId);
|
||||
return renditionsForMediaAssetId(mediaAssetId);
|
||||
}
|
||||
|
||||
public List<MediaRenditionRespVO> renditionsForMediaAssetId(Long mediaAssetId) {
|
||||
return transcodeService.listRenditionsByMediaAssetId(mediaAssetId).stream()
|
||||
.sorted(RENDITION_ORDER)
|
||||
.map(this::toRenditionVo)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public MediaThumbnailRespVO thumbnails(Long mediaAssetId,
|
||||
Integer pageIndex, Integer pageSize) {
|
||||
requireMedia(mediaAssetId);
|
||||
int safeIndex = pageIndex == null || pageIndex < 1 ? DEFAULT_PAGE_INDEX : pageIndex;
|
||||
int safeSize = pageSize == null || pageSize < 1
|
||||
? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
|
||||
TranscodeTask task = transcodeService.findLatestTaskByMediaAssetId(mediaAssetId);
|
||||
if (task == null || task.getEpisodeId() == null || task.getEpisodeId() <= 0) {
|
||||
return MediaThumbnailRespVO.empty(mediaAssetId, null,
|
||||
MediaThumbnailRespVO.STATUS_NO_EPISODE, safeIndex, safeSize);
|
||||
}
|
||||
return thumbnailForEpisode(
|
||||
task.getEpisodeId(), mediaAssetId, safeIndex, safeSize);
|
||||
}
|
||||
|
||||
public MediaThumbnailRespVO thumbnailForEpisode(Long episodeId, Long mediaAssetId,
|
||||
Integer pageIndex, Integer pageSize) {
|
||||
int safeIndex = pageIndex == null || pageIndex < 1 ? DEFAULT_PAGE_INDEX : pageIndex;
|
||||
int safeSize = pageSize == null || pageSize < 1
|
||||
? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE);
|
||||
MediaThumbnail thumbnail = transcodeService.findThumbnailByEpisodeId(episodeId);
|
||||
if (thumbnail == null) {
|
||||
return MediaThumbnailRespVO.empty(mediaAssetId, episodeId,
|
||||
MediaThumbnailRespVO.STATUS_EMPTY, safeIndex, safeSize);
|
||||
}
|
||||
if (!MediaThumbnail.STATUS_READY.equals(thumbnail.getStatus())) {
|
||||
String status = StringUtils.hasText(thumbnail.getStatus())
|
||||
? thumbnail.getStatus() : MediaThumbnailRespVO.STATUS_INVALID;
|
||||
MediaThumbnailRespVO failed = MediaThumbnailRespVO.empty(
|
||||
mediaAssetId, episodeId, status, safeIndex, safeSize);
|
||||
failed.setErrorMsg(thumbnail.getErrorMsg());
|
||||
return failed;
|
||||
}
|
||||
return readyThumbnail(thumbnail, mediaAssetId, safeIndex, safeSize);
|
||||
}
|
||||
|
||||
public TranscodeTaskRespVO latestTask(Long mediaAssetId) {
|
||||
return TranscodeTaskRespVO.from(
|
||||
transcodeService.findLatestTaskByMediaAssetId(mediaAssetId));
|
||||
}
|
||||
|
||||
public Long resolveMediaAssetId(String videoUrl) {
|
||||
if (!StringUtils.hasText(videoUrl)) {
|
||||
return null;
|
||||
}
|
||||
String objectKey = storageService.objectKeyOf(videoUrl);
|
||||
if (!StringUtils.hasText(objectKey)) {
|
||||
return null;
|
||||
}
|
||||
MediaAsset asset = mediaAssetMapper.selectOne(Wrappers.<MediaAsset>lambdaQuery()
|
||||
.eq(MediaAsset::getObjectKey, objectKey)
|
||||
.last("limit 1"));
|
||||
return asset == null ? null : asset.getId();
|
||||
}
|
||||
|
||||
private MediaThumbnailRespVO readyThumbnail(MediaThumbnail thumbnail, Long mediaAssetId,
|
||||
int pageIndex, int pageSize) {
|
||||
List<ThumbnailVttParser.Cue> cues;
|
||||
try {
|
||||
cues = readCues(thumbnail);
|
||||
} catch (Exception ex) {
|
||||
MediaThumbnailRespVO invalid = MediaThumbnailRespVO.empty(
|
||||
mediaAssetId, thumbnail.getEpisodeId(),
|
||||
MediaThumbnailRespVO.STATUS_INVALID, pageIndex, pageSize);
|
||||
invalid.setErrorMsg("缩略图 VTT 读取失败:" + messageOf(ex));
|
||||
return invalid;
|
||||
}
|
||||
List<MediaThumbnailFrameVO> frames = cues.stream()
|
||||
.map(cue -> toFrame(thumbnail, cue))
|
||||
.toList();
|
||||
PageResult<MediaThumbnailFrameVO> page = page(frames, pageIndex, pageSize);
|
||||
MediaThumbnailRespVO result = new MediaThumbnailRespVO();
|
||||
result.setMediaAssetId(mediaAssetId);
|
||||
result.setEpisodeId(thumbnail.getEpisodeId());
|
||||
result.setStatus(MediaThumbnail.STATUS_READY);
|
||||
result.setErrorMsg(thumbnail.getErrorMsg());
|
||||
result.setPageIndex(pageIndex);
|
||||
result.setPageSize(pageSize);
|
||||
result.setFrames(page);
|
||||
result.setSprite(toSprite(thumbnail, cues));
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ThumbnailVttParser.Cue> readCues(MediaThumbnail thumbnail) throws IOException {
|
||||
String objectKey = thumbnail.getVttObjectKey();
|
||||
if (!StringUtils.hasText(objectKey)) {
|
||||
return List.of();
|
||||
}
|
||||
return ThumbnailVttParser.parse(
|
||||
Files.readString(storageService.fileFor(objectKey), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private PageResult<MediaThumbnailFrameVO> page(
|
||||
List<MediaThumbnailFrameVO> frames, int pageIndex, int pageSize) {
|
||||
long offsetLong = (long) (pageIndex - 1) * pageSize;
|
||||
if (offsetLong >= frames.size()) {
|
||||
return new PageResult<>(frames.size(), List.of());
|
||||
}
|
||||
int offset = (int) offsetLong;
|
||||
int end = Math.min(offset + pageSize, frames.size());
|
||||
return new PageResult<>(frames.size(), frames.subList(offset, end));
|
||||
}
|
||||
|
||||
private MediaThumbnailFrameVO toFrame(
|
||||
MediaThumbnail thumbnail, ThumbnailVttParser.Cue cue) {
|
||||
String spriteUrl = spriteUrl(thumbnail.getSpritePrefix(), cue.spriteFile());
|
||||
MediaThumbnailFrameVO frame = new MediaThumbnailFrameVO();
|
||||
frame.setFrameIndex(frameIndex(thumbnail, cue));
|
||||
frame.setTimeMs(cue.startMs());
|
||||
frame.setEndTimeMs(cue.endMs());
|
||||
frame.setSpriteUrl(spriteUrl);
|
||||
frame.setImageUrl(spriteUrl + "#xywh=" + cue.x() + "," + cue.y()
|
||||
+ "," + cue.width() + "," + cue.height());
|
||||
frame.setX(cue.x());
|
||||
frame.setY(cue.y());
|
||||
frame.setWidth(cue.width());
|
||||
frame.setHeight(cue.height());
|
||||
return frame;
|
||||
}
|
||||
|
||||
private int frameIndex(MediaThumbnail thumbnail, ThumbnailVttParser.Cue cue) {
|
||||
int intervalMs = Math.max(1, thumbnail.getIntervalSec() == null
|
||||
? 1000 : thumbnail.getIntervalSec() * 1000);
|
||||
return (int) (cue.startMs() / intervalMs);
|
||||
}
|
||||
|
||||
private MediaThumbnailSpriteVO toSprite(
|
||||
MediaThumbnail thumbnail, List<ThumbnailVttParser.Cue> cues) {
|
||||
Set<String> urls = new LinkedHashSet<>();
|
||||
cues.forEach(cue -> urls.add(
|
||||
spriteUrl(thumbnail.getSpritePrefix(), cue.spriteFile())));
|
||||
List<String> spriteUrls = new ArrayList<>(urls);
|
||||
MediaThumbnailSpriteVO sprite = new MediaThumbnailSpriteVO();
|
||||
sprite.setSpriteUrls(spriteUrls);
|
||||
sprite.setSpriteUrl(spriteUrls.isEmpty() ? null : spriteUrls.get(0));
|
||||
sprite.setVttUrl(signedUrl(thumbnail.getVttObjectKey()));
|
||||
sprite.setColumns(thumbnail.getColumns());
|
||||
sprite.setRows(thumbnail.getRows());
|
||||
sprite.setTotalFrames(thumbnail.getFrameCount() == null || thumbnail.getFrameCount() <= 0
|
||||
? cues.size() : thumbnail.getFrameCount());
|
||||
sprite.setThumbWidth(thumbnail.getThumbWidth());
|
||||
sprite.setThumbHeight(thumbnail.getThumbHeight());
|
||||
sprite.setIntervalSeconds(thumbnail.getIntervalSec());
|
||||
return sprite;
|
||||
}
|
||||
|
||||
private String spriteUrl(String spritePrefix, String spriteFile) {
|
||||
if (!StringUtils.hasText(spritePrefix) || !StringUtils.hasText(spriteFile)) {
|
||||
return "";
|
||||
}
|
||||
int slash = spritePrefix.lastIndexOf('/');
|
||||
String directory = slash < 0 ? "" : spritePrefix.substring(0, slash + 1);
|
||||
return signedUrl(directory + spriteFile);
|
||||
}
|
||||
|
||||
private String signedUrl(String objectKey) {
|
||||
if (!StringUtils.hasText(objectKey)) {
|
||||
return null;
|
||||
}
|
||||
return signedPath(storageService.toPublicUrl(objectKey));
|
||||
}
|
||||
|
||||
private String signedPath(String publicUrl) {
|
||||
if (!StringUtils.hasText(publicUrl)) {
|
||||
return null;
|
||||
}
|
||||
String signed = signer.sign(publicUrl);
|
||||
return StringUtils.hasText(signed) ? signed : publicUrl;
|
||||
}
|
||||
|
||||
private MediaRenditionRespVO toRenditionVo(MediaRendition rendition) {
|
||||
MediaRenditionRespVO vo = new MediaRenditionRespVO();
|
||||
vo.setId(rendition.getId());
|
||||
vo.setQuality(rendition.getQuality());
|
||||
vo.setQualityName(rendition.getQuality() == null
|
||||
? null : rendition.getQuality().toUpperCase(Locale.ROOT));
|
||||
vo.setWidth(rendition.getWidth());
|
||||
vo.setHeight(rendition.getHeight());
|
||||
vo.setResolution(rendition.getWidth() + "x" + rendition.getHeight());
|
||||
vo.setVideoBitrateKbps(rendition.getVideoBitrate());
|
||||
vo.setAudioBitrateKbps(rendition.getAudioBitrate());
|
||||
vo.setVideoCodec("H.264");
|
||||
vo.setAudioCodec("AAC");
|
||||
vo.setContainer(rendition.getFormat());
|
||||
vo.setHlsUrl(signedPath(rendition.getPlayUrl()));
|
||||
vo.setSizeBytes(rendition.getSizeBytes());
|
||||
vo.setDurationSec(rendition.getDurationSec());
|
||||
vo.setSegmentCount(rendition.getSegmentCount());
|
||||
vo.setStatus(rendition.getStatus());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void requireMedia(Long mediaAssetId) {
|
||||
MediaAsset asset = mediaAssetId == null ? null : mediaAssetMapper.selectById(mediaAssetId);
|
||||
if (asset == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在");
|
||||
}
|
||||
}
|
||||
|
||||
private String messageOf(Exception exception) {
|
||||
String message = exception.getMessage();
|
||||
return StringUtils.hasText(message) ? message : exception.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** 解析 worker 生成的 WebVTT 雪碧图索引。 */
|
||||
public final class ThumbnailVttParser {
|
||||
|
||||
private static final Pattern TIMING = Pattern.compile(
|
||||
"^(\\d{2}:\\d{2}:\\d{2}\\.\\d{3})\\s+-->\\s+"
|
||||
+ "(\\d{2}:\\d{2}:\\d{2}\\.\\d{3})$");
|
||||
private static final Pattern IMAGE = Pattern.compile(
|
||||
"^([^#\\s]+)#xywh=(\\d+),(\\d+),(\\d+),(\\d+)$");
|
||||
private static final int HOURS_PER_DAY = 24;
|
||||
|
||||
private ThumbnailVttParser() {
|
||||
}
|
||||
|
||||
public static List<Cue> parse(String content) {
|
||||
if (content == null || content.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
String[] lines = content.replace("\r\n", "\n").split("\n");
|
||||
List<Cue> cues = new ArrayList<>();
|
||||
for (int index = 0; index < lines.length; index++) {
|
||||
Matcher timing = TIMING.matcher(lines[index].trim());
|
||||
if (!timing.matches()) {
|
||||
continue;
|
||||
}
|
||||
String imageLine = nextNonBlank(lines, index + 1);
|
||||
Matcher image = IMAGE.matcher(imageLine);
|
||||
if (!image.matches()) {
|
||||
continue;
|
||||
}
|
||||
cues.add(new Cue(
|
||||
timestampMs(timing.group(1)), timestampMs(timing.group(2)),
|
||||
image.group(1), integer(image.group(2)), integer(image.group(3)),
|
||||
integer(image.group(4)), integer(image.group(5))));
|
||||
}
|
||||
return cues;
|
||||
}
|
||||
|
||||
private static String nextNonBlank(String[] lines, int start) {
|
||||
for (int index = start; index < lines.length; index++) {
|
||||
String line = lines[index].trim();
|
||||
if (!line.isEmpty()) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static long timestampMs(String value) {
|
||||
int hours = Integer.parseInt(value.substring(0, 2)) % HOURS_PER_DAY;
|
||||
int minutes = Integer.parseInt(value.substring(3, 5));
|
||||
int seconds = Integer.parseInt(value.substring(6, 8));
|
||||
int millis = Integer.parseInt(value.substring(9, 12));
|
||||
return ((hours * 60L + minutes) * 60L + seconds) * 1000L + millis;
|
||||
}
|
||||
|
||||
private static int integer(String value) {
|
||||
return Integer.parseInt(value);
|
||||
}
|
||||
|
||||
public record Cue(long startMs, long endMs, String spriteFile,
|
||||
int x, int y, int width, int height) {
|
||||
}
|
||||
}
|
||||
+51
-18
@@ -12,6 +12,7 @@ import com.fruitshorts.media.dal.mapper.MediaRenditionMapper;
|
||||
import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
|
||||
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
|
||||
import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
|
||||
import com.fruitshorts.media.vo.TranscodeQueueStatsVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -77,7 +78,7 @@ public class TranscodeService {
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TranscodeTriggerRespVO trigger(Long mediaAssetId, String sourceUrl) {
|
||||
TranscodeTask latest = latestTask(mediaAssetId);
|
||||
TranscodeTask latest = findLatestTaskByMediaAssetId(mediaAssetId);
|
||||
if (latest != null && isActive(latest.getStatus())) {
|
||||
return response(latest.getId(), latest.getStatus());
|
||||
}
|
||||
@@ -213,6 +214,40 @@ public class TranscodeService {
|
||||
.orderByAsc(MediaRendition::getVideoBitrate));
|
||||
}
|
||||
|
||||
/** 后台按媒资查询全部清晰度产物。 */
|
||||
public List<MediaRendition> listRenditionsByMediaAssetId(Long mediaAssetId) {
|
||||
if (mediaAssetId == null) {
|
||||
return List.of();
|
||||
}
|
||||
return mediaRenditionMapper.selectList(Wrappers.<MediaRendition>lambdaQuery()
|
||||
.eq(MediaRendition::getMediaAssetId, mediaAssetId)
|
||||
.orderByDesc(MediaRendition::getHeight)
|
||||
.orderByDesc(MediaRendition::getVideoBitrate)
|
||||
.orderByAsc(MediaRendition::getId));
|
||||
}
|
||||
|
||||
/** 后台查询媒资最新一次转码任务。 */
|
||||
public TranscodeTask findLatestTaskByMediaAssetId(Long mediaAssetId) {
|
||||
if (mediaAssetId == null) {
|
||||
return null;
|
||||
}
|
||||
return transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery()
|
||||
.eq(TranscodeTask::getMediaAssetId, mediaAssetId)
|
||||
.orderByDesc(TranscodeTask::getId)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
/** 后台查询分集缩略图记录,不限 READY,便于展示失败原因。 */
|
||||
public MediaThumbnail findThumbnailByEpisodeId(Long episodeId) {
|
||||
if (episodeId == null) {
|
||||
return null;
|
||||
}
|
||||
return mediaThumbnailMapper.selectOne(Wrappers.<MediaThumbnail>lambdaQuery()
|
||||
.eq(MediaThumbnail::getEpisodeId, episodeId)
|
||||
.orderByDesc(MediaThumbnail::getId)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
/** 播放侧查询单集 READY 缩略图轴。 */
|
||||
public MediaThumbnail findReadyThumbnail(Long episodeId) {
|
||||
if (episodeId == null) {
|
||||
@@ -245,15 +280,16 @@ public class TranscodeService {
|
||||
* 队列类指标看的是"当前积压",用累计值才有意义。
|
||||
* 产物数取自 media_rendition,反映已经生成多少路码率文件。
|
||||
*/
|
||||
public Map<String, Object> stats() {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("queued", countByStatus(STATUS_QUEUED));
|
||||
data.put("running", countByStatus(STATUS_RUNNING));
|
||||
data.put("success", countByStatus(STATUS_SUCCESS));
|
||||
data.put("failed", countByStatus(STATUS_FAILED));
|
||||
data.put("canceled", countByStatus("CANCELED"));
|
||||
data.put("renditions", mediaRenditionMapper.selectCount(null));
|
||||
return data;
|
||||
public TranscodeQueueStatsVO stats() {
|
||||
long total = orZero(transcodeTaskMapper.selectCount(null));
|
||||
long queued = countByStatus(STATUS_QUEUED);
|
||||
long running = countByStatus(STATUS_RUNNING);
|
||||
long success = countByStatus(STATUS_SUCCESS);
|
||||
long failed = countByStatus(STATUS_FAILED);
|
||||
long canceled = countByStatus("CANCELED");
|
||||
long renditions = orZero(mediaRenditionMapper.selectCount(null));
|
||||
return new TranscodeQueueStatsVO(
|
||||
total, queued, running, success, failed, canceled, renditions);
|
||||
}
|
||||
|
||||
/** 失败任务明细:带 error_msg 与 retry_count,便于运营判断是重试还是排查片源。 */
|
||||
@@ -264,16 +300,13 @@ public class TranscodeService {
|
||||
.last("limit " + (limit <= 0 || limit > 100 ? 20 : limit)));
|
||||
}
|
||||
|
||||
private Long countByStatus(String status) {
|
||||
return transcodeTaskMapper.selectCount(Wrappers.<TranscodeTask>lambdaQuery()
|
||||
.eq(TranscodeTask::getStatus, status));
|
||||
private long countByStatus(String status) {
|
||||
return orZero(transcodeTaskMapper.selectCount(Wrappers.<TranscodeTask>lambdaQuery()
|
||||
.eq(TranscodeTask::getStatus, status)));
|
||||
}
|
||||
|
||||
private TranscodeTask latestTask(Long mediaAssetId) {
|
||||
return transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery()
|
||||
.eq(TranscodeTask::getMediaAssetId, mediaAssetId)
|
||||
.orderByDesc(TranscodeTask::getId)
|
||||
.last("limit 1"));
|
||||
private long orZero(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
private boolean isActive(String status) {
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/** 后台媒资清晰度产物。 */
|
||||
@Data
|
||||
@Schema(description = "媒资清晰度产物")
|
||||
public class MediaRenditionRespVO {
|
||||
|
||||
private Long id;
|
||||
@Schema(description = "档位标识")
|
||||
private String quality;
|
||||
@Schema(description = "档位名称")
|
||||
private String qualityName;
|
||||
private Integer width;
|
||||
private Integer height;
|
||||
@Schema(description = "宽x高")
|
||||
private String resolution;
|
||||
private Integer videoBitrateKbps;
|
||||
private Integer audioBitrateKbps;
|
||||
private String videoCodec;
|
||||
private String audioCodec;
|
||||
@Schema(description = "容器类型")
|
||||
private String container;
|
||||
@Schema(description = "HLS 播放地址")
|
||||
private String hlsUrl;
|
||||
private Long sizeBytes;
|
||||
private Integer durationSec;
|
||||
private Integer segmentCount;
|
||||
private String status;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/** VTT 时间轴中的单个缩略图帧。 */
|
||||
@Data
|
||||
@Schema(description = "缩略图帧")
|
||||
public class MediaThumbnailFrameVO {
|
||||
|
||||
@Schema(description = "帧序号,从 0 开始")
|
||||
private Integer frameIndex;
|
||||
private Long timeMs;
|
||||
private Long endTimeMs;
|
||||
@Schema(description = "带 xywh 片段的图片地址")
|
||||
private String imageUrl;
|
||||
private String spriteUrl;
|
||||
private Integer x;
|
||||
private Integer y;
|
||||
private Integer width;
|
||||
private Integer height;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 后台单集缩略图轴响应。 */
|
||||
@Data
|
||||
@Schema(description = "缩略图轴信息")
|
||||
public class MediaThumbnailRespVO {
|
||||
|
||||
public static final String STATUS_EMPTY = "EMPTY";
|
||||
public static final String STATUS_INVALID = "INVALID";
|
||||
public static final String STATUS_NO_EPISODE = "NO_EPISODE";
|
||||
|
||||
private Long mediaAssetId;
|
||||
private Long episodeId;
|
||||
private String status;
|
||||
private String errorMsg;
|
||||
private Integer pageIndex;
|
||||
private Integer pageSize;
|
||||
private PageResult<MediaThumbnailFrameVO> frames = PageResult.empty();
|
||||
private MediaThumbnailSpriteVO sprite;
|
||||
|
||||
public static MediaThumbnailRespVO empty(Long mediaAssetId, Long episodeId,
|
||||
String status, int pageIndex, int pageSize) {
|
||||
MediaThumbnailRespVO vo = new MediaThumbnailRespVO();
|
||||
vo.setMediaAssetId(mediaAssetId);
|
||||
vo.setEpisodeId(episodeId);
|
||||
vo.setStatus(status);
|
||||
vo.setPageIndex(pageIndex);
|
||||
vo.setPageSize(pageSize);
|
||||
vo.setFrames(new PageResult<>(0L, List.of()));
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 雪碧图与 VTT 元数据。 */
|
||||
@Data
|
||||
@Schema(description = "雪碧图信息")
|
||||
public class MediaThumbnailSpriteVO {
|
||||
|
||||
private String spriteUrl;
|
||||
private List<String> spriteUrls;
|
||||
private String vttUrl;
|
||||
private Integer columns;
|
||||
private Integer rows;
|
||||
private Integer totalFrames;
|
||||
private Integer thumbWidth;
|
||||
private Integer thumbHeight;
|
||||
private Integer intervalSeconds;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** 转码队列累计统计。 */
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "转码队列统计")
|
||||
public class TranscodeQueueStatsVO {
|
||||
|
||||
private long total;
|
||||
private long queued;
|
||||
private long running;
|
||||
private long success;
|
||||
private long failed;
|
||||
private long canceled;
|
||||
private long renditions;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.vo;
|
||||
|
||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 后台转码任务快照。 */
|
||||
@Data
|
||||
@Schema(description = "转码任务")
|
||||
public class TranscodeTaskRespVO {
|
||||
|
||||
private Long id;
|
||||
private Long mediaAssetId;
|
||||
private Long dramaId;
|
||||
private Long episodeId;
|
||||
private String sourceUrl;
|
||||
private String profile;
|
||||
private String status;
|
||||
private Integer progress;
|
||||
private Integer retryCount;
|
||||
private String errorMsg;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime finishedAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public static TranscodeTaskRespVO from(TranscodeTask task) {
|
||||
if (task == null) {
|
||||
return null;
|
||||
}
|
||||
TranscodeTaskRespVO vo = new TranscodeTaskRespVO();
|
||||
vo.setId(task.getId());
|
||||
vo.setMediaAssetId(task.getMediaAssetId());
|
||||
vo.setDramaId(task.getDramaId());
|
||||
vo.setEpisodeId(task.getEpisodeId());
|
||||
vo.setSourceUrl(task.getSourceUrl());
|
||||
vo.setProfile(task.getProfile());
|
||||
vo.setStatus(task.getStatus());
|
||||
vo.setProgress(task.getProgress());
|
||||
vo.setRetryCount(task.getRetryCount());
|
||||
vo.setErrorMsg(task.getErrorMsg());
|
||||
vo.setStartedAt(task.getStartedAt());
|
||||
vo.setFinishedAt(task.getFinishedAt());
|
||||
vo.setCreatedAt(task.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
+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