feat(media): 新增清晰度与帧图查询接口
This commit is contained in:
1 parent
090f149b31
commit
345408d5e7
18 files changed
+887
-31
No files matched your search
@@ -15,6 +15,16 @@
|
||||
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 解析与分页边界测试。
|
||||
|
||||
@@ -25339,3 +25339,54 @@ $ mvn -pl fruitshorts-module-media test
|
||||
`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();
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user