feat(media): 新增清晰度与帧图查询接口

This commit is contained in:
Zhou Weidong committed 2026-10-22 14:14:13 +08:00
1 parent 090f149b31
commit 345408d5e7
18 files changed
+887 -31

No files matched your search

@@ -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());
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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);
@@ -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;
}
}
@@ -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;
}
@@ -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();
}