From 345408d5e7cb0f424d7e7543ddb46bcde03bcd9e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 14:14:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(media):=20=E6=96=B0=E5=A2=9E=E6=B8=85?= =?UTF-8?q?=E6=99=B0=E5=BA=A6=E4=B8=8E=E5=B8=A7=E5=9B=BE=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-22-media-transcode-output-api.md | 10 + docs/plans/PROGRESS.md | 51 ++++ .../controller/DashboardAdminController.java | 3 +- .../EpisodeTranscodeController.java | 32 +++ .../MediaTranscodeOutputController.java | 46 +++ .../admin/service/DashboardService.java | 29 +- .../service/EpisodeTranscodeAdminService.java | 91 ++++++ .../admin/vo/EpisodeTranscodeRespVO.java | 26 ++ .../admin/vo/TranscodeDashboardRespVO.java | 22 ++ .../service/MediaTranscodeOutputService.java | 271 ++++++++++++++++++ .../media/service/ThumbnailVttParser.java | 72 +++++ .../media/service/TranscodeService.java | 69 +++-- .../media/vo/MediaRenditionRespVO.java | 34 +++ .../media/vo/MediaThumbnailFrameVO.java | 24 ++ .../media/vo/MediaThumbnailRespVO.java | 40 +++ .../media/vo/MediaThumbnailSpriteVO.java | 24 ++ .../media/vo/TranscodeQueueStatsVO.java | 24 ++ .../media/vo/TranscodeTaskRespVO.java | 50 ++++ 18 files changed, 887 insertions(+), 31 deletions(-) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeTranscodeController.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaTranscodeOutputController.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminService.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EpisodeTranscodeRespVO.java create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeDashboardRespVO.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeOutputService.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/ThumbnailVttParser.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaRenditionRespVO.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailFrameVO.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailRespVO.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailSpriteVO.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeQueueStatsVO.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeTaskRespVO.java diff --git a/docs/plans/2026-10-22-media-transcode-output-api.md b/docs/plans/2026-10-22-media-transcode-output-api.md index 66530ba..325e3c3 100644 --- a/docs/plans/2026-10-22-media-transcode-output-api.md +++ b/docs/plans/2026-10-22-media-transcode-output-api.md @@ -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`,同时返回 sprite/VTT 元数据; +空产物 status 为 `EMPTY`,无绑定分集为 `NO_EPISODE`,损坏 VTT 为 `INVALID`。 + ## TDD - 先提交媒体有/无产物、不存在、VTT 解析与分页边界测试。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 593f9bc..880cf06 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -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` / `PageResult`, + 新接口不再返回 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 +链路未回归。 + diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DashboardAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DashboardAdminController.java index 7a44140..854e82b 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DashboardAdminController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DashboardAdminController.java @@ -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> transcode() { + public Result transcode() { return Result.ok(dashboardService.transcode()); } } diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeTranscodeController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeTranscodeController.java new file mode 100644 index 0000000..8cfc7d1 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeTranscodeController.java @@ -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 detail(@PathVariable Long id) { + return Result.ok(service.detail(id)); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaTranscodeOutputController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaTranscodeOutputController.java new file mode 100644 index 0000000..09b9859 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaTranscodeOutputController.java @@ -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> renditions(@PathVariable Long id) { + return Result.ok(outputService.renditions(id)); + } + + @Operation(summary = "分页查询媒资帧图产物") + @GetMapping("/{id}/thumbnails") + @PreAuthorize("@ss.hasPermission('episode:upload')") + public Result thumbnails( + @PathVariable Long id, + @RequestParam(defaultValue = "1") Integer pageIndex, + @RequestParam(defaultValue = "100") Integer pageSize) { + return Result.ok(outputService.thumbnails(id, pageIndex, pageSize)); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java index 87c9a59..fcec80c 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/DashboardService.java @@ -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 transcode() { - Map 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 costMetrics(LocalDateTime sevenDaysAgo) { Map cost = new LinkedHashMap<>(); - Map 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.lambdaQuery() .ge(AppUserOtp::getCreatedAt, sevenDaysAgo))); cost.put("cdnTrafficBytes", null); diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminService.java new file mode 100644 index 0000000..ef0bae3 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminService.java @@ -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; + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EpisodeTranscodeRespVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EpisodeTranscodeRespVO.java new file mode 100644 index 0000000..4f40e2f --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EpisodeTranscodeRespVO.java @@ -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 renditions = List.of(); + private MediaThumbnailRespVO thumbnails; +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeDashboardRespVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeDashboardRespVO.java new file mode 100644 index 0000000..1c25936 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/TranscodeDashboardRespVO.java @@ -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 failedTasks = List.of(); + private List notes = List.of(); +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeOutputService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeOutputService.java new file mode 100644 index 0000000..7cbfc27 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeOutputService.java @@ -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 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 renditions(Long mediaAssetId) { + requireMedia(mediaAssetId); + return renditionsForMediaAssetId(mediaAssetId); + } + + public List 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.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 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 frames = cues.stream() + .map(cue -> toFrame(thumbnail, cue)) + .toList(); + PageResult 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 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 page( + List 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 cues) { + Set urls = new LinkedHashSet<>(); + cues.forEach(cue -> urls.add( + spriteUrl(thumbnail.getSpritePrefix(), cue.spriteFile()))); + List 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(); + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/ThumbnailVttParser.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/ThumbnailVttParser.java new file mode 100644 index 0000000..11ff2ea --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/ThumbnailVttParser.java @@ -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 parse(String content) { + if (content == null || content.isBlank()) { + return List.of(); + } + String[] lines = content.replace("\r\n", "\n").split("\n"); + List 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) { + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java index 8f6df97..a59f135 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java @@ -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 listRenditionsByMediaAssetId(Long mediaAssetId) { + if (mediaAssetId == null) { + return List.of(); + } + return mediaRenditionMapper.selectList(Wrappers.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.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.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 stats() { - Map 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.lambdaQuery() - .eq(TranscodeTask::getStatus, status)); + private long countByStatus(String status) { + return orZero(transcodeTaskMapper.selectCount(Wrappers.lambdaQuery() + .eq(TranscodeTask::getStatus, status))); } - private TranscodeTask latestTask(Long mediaAssetId) { - return transcodeTaskMapper.selectOne(Wrappers.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) { diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaRenditionRespVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaRenditionRespVO.java new file mode 100644 index 0000000..987d406 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaRenditionRespVO.java @@ -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; +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailFrameVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailFrameVO.java new file mode 100644 index 0000000..6d845bf --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailFrameVO.java @@ -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; +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailRespVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailRespVO.java new file mode 100644 index 0000000..694f814 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailRespVO.java @@ -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 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; + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailSpriteVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailSpriteVO.java new file mode 100644 index 0000000..a065044 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaThumbnailSpriteVO.java @@ -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 spriteUrls; + private String vttUrl; + private Integer columns; + private Integer rows; + private Integer totalFrames; + private Integer thumbWidth; + private Integer thumbHeight; + private Integer intervalSeconds; +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeQueueStatsVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeQueueStatsVO.java new file mode 100644 index 0000000..d169268 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeQueueStatsVO.java @@ -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; +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeTaskRespVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeTaskRespVO.java new file mode 100644 index 0000000..ac51407 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeTaskRespVO.java @@ -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; + } +}