fix(media): 补齐分集转码绑定与产物回填
This commit is contained in:
1 parent
6bdd245a83
commit
7d563f2cd6
18 files changed
+812
-25
No files matched your search
+51
@@ -0,0 +1,51 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.media.dal.entity.MediaAsset;
|
||||
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 把分集视频地址对应的媒资与转码任务绑定到 dramaId / episodeId。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class EpisodeTranscodeBindingService {
|
||||
|
||||
private final MediaAssetMapper mediaAssetMapper;
|
||||
private final MediaStorageService storageService;
|
||||
private final TranscodeService transcodeService;
|
||||
|
||||
/**
|
||||
* 按视频 objectKey 查找媒资并绑定任务。
|
||||
*
|
||||
* @return 命中的 mediaAssetId;外部视频或未上传媒资返回 null
|
||||
*/
|
||||
public Long bind(Long episodeId, Long dramaId, String videoUrl) {
|
||||
if (episodeId == null || episodeId <= 0
|
||||
|| dramaId == null || dramaId <= 0
|
||||
|| !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"));
|
||||
if (asset == null) {
|
||||
log.warn("分集 {} 的视频地址未找到 media_asset:{}", episodeId, videoUrl);
|
||||
return null;
|
||||
}
|
||||
transcodeService.bindToEpisode(
|
||||
asset.getId(), dramaId, episodeId, asset.getSourceUrl());
|
||||
return asset.getId();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
/**
|
||||
* 转码完成后回填分集 HLS 状态的跨域端口,由 content 域实现。
|
||||
*/
|
||||
public interface EpisodeTranscodeCompletionPort {
|
||||
|
||||
/** 写入分集 HLS 地址并标记转码成功。 */
|
||||
void markEpisodeReady(Long episodeId, String hlsUrl, Integer durationSec);
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.media.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.media.dal.entity.MediaThumbnail;
|
||||
import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||
import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 缩略图登记使用独立事务,DB 异常不得回滚主 HLS 成功事务。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ThumbnailRecordService {
|
||||
|
||||
private final MediaThumbnailMapper mediaThumbnailMapper;
|
||||
|
||||
/** 写入或覆盖单集缩略图时间轴记录。 */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
|
||||
public void upsert(TranscodeTask task, MediaThumbnail thumbnail) {
|
||||
Long episodeId = task.getEpisodeId();
|
||||
if (episodeId == null || episodeId <= 0) {
|
||||
return;
|
||||
}
|
||||
MediaThumbnail existing = mediaThumbnailMapper.selectOne(
|
||||
Wrappers.<MediaThumbnail>lambdaQuery()
|
||||
.eq(MediaThumbnail::getEpisodeId, episodeId)
|
||||
.last("limit 1"));
|
||||
thumbnail.setDramaId(task.getDramaId() == null ? 0L : task.getDramaId());
|
||||
thumbnail.setEpisodeId(episodeId);
|
||||
if (existing == null) {
|
||||
mediaThumbnailMapper.insert(thumbnail);
|
||||
return;
|
||||
}
|
||||
thumbnail.setId(existing.getId());
|
||||
mediaThumbnailMapper.updateById(thumbnail);
|
||||
}
|
||||
}
|
||||
+79
-16
@@ -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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -29,6 +30,7 @@ import java.util.Collection;
|
||||
* 与在线服务物理隔离,避免转码抢占播放链路的 CPU。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class TranscodeService {
|
||||
|
||||
@@ -42,6 +44,7 @@ public class TranscodeService {
|
||||
private final TranscodeTaskMapper transcodeTaskMapper;
|
||||
private final MediaRenditionMapper mediaRenditionMapper;
|
||||
private final MediaThumbnailMapper mediaThumbnailMapper;
|
||||
private final ThumbnailRecordService thumbnailRecordService;
|
||||
|
||||
/** 上传完成后入队;同一媒资重复入队时复用未完成的任务,避免重复转码。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -51,6 +54,7 @@ public class TranscodeService {
|
||||
.in(TranscodeTask::getStatus, List.of(STATUS_QUEUED, STATUS_RUNNING))
|
||||
.last("limit 1"));
|
||||
if (existing != null) {
|
||||
updateTaskBinding(existing, dramaId, episodeId, sourceUrl);
|
||||
return existing.getId();
|
||||
}
|
||||
TranscodeTask task = new TranscodeTask();
|
||||
@@ -67,10 +71,49 @@ public class TranscodeService {
|
||||
return task.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 分集保存视频地址后绑定任务;成功任务复用并同步回填已有的零绑定产物。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long bindToEpisode(Long mediaAssetId, Long dramaId, Long episodeId, String sourceUrl) {
|
||||
if (mediaAssetId == null || mediaAssetId <= 0
|
||||
|| dramaId == null || dramaId <= 0
|
||||
|| episodeId == null || episodeId <= 0) {
|
||||
return null;
|
||||
}
|
||||
TranscodeTask latest = transcodeTaskMapper.selectOne(
|
||||
Wrappers.<TranscodeTask>lambdaQuery()
|
||||
.eq(TranscodeTask::getMediaAssetId, mediaAssetId)
|
||||
.orderByDesc(TranscodeTask::getId)
|
||||
.last("limit 1"));
|
||||
if (latest == null
|
||||
|| STATUS_FAILED.equals(latest.getStatus())
|
||||
|| "CANCELED".equals(latest.getStatus())) {
|
||||
return enqueue(mediaAssetId, dramaId, episodeId, sourceUrl);
|
||||
}
|
||||
if (latest.getEpisodeId() != null && latest.getEpisodeId() > 0
|
||||
&& !latest.getEpisodeId().equals(episodeId)) {
|
||||
log.warn("媒资 {} 已绑定分集 {},拒绝改绑到 {}", mediaAssetId,
|
||||
latest.getEpisodeId(), episodeId);
|
||||
return latest.getId();
|
||||
}
|
||||
updateTaskBinding(latest, dramaId, episodeId, sourceUrl);
|
||||
if (STATUS_SUCCESS.equals(latest.getStatus())) {
|
||||
mediaRenditionMapper.update(null, Wrappers.<MediaRendition>lambdaUpdate()
|
||||
.eq(MediaRendition::getMediaAssetId, mediaAssetId)
|
||||
.and(wrapper -> wrapper.eq(MediaRendition::getEpisodeId, 0L)
|
||||
.or().isNull(MediaRendition::getEpisodeId))
|
||||
.set(MediaRendition::getDramaId, dramaId)
|
||||
.set(MediaRendition::getEpisodeId, episodeId));
|
||||
}
|
||||
return latest.getId();
|
||||
}
|
||||
|
||||
/** worker 认领任务。并发下依赖状态条件更新,抢不到返回 null。 */
|
||||
public TranscodeTask claimNext() {
|
||||
TranscodeTask candidate = transcodeTaskMapper.selectOne(Wrappers.<TranscodeTask>lambdaQuery()
|
||||
.eq(TranscodeTask::getStatus, STATUS_QUEUED)
|
||||
.gt(TranscodeTask::getEpisodeId, 0)
|
||||
.orderByAsc(TranscodeTask::getId)
|
||||
.last("limit 1"));
|
||||
if (candidate == null) {
|
||||
@@ -79,6 +122,7 @@ public class TranscodeService {
|
||||
int updated = transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
|
||||
.eq(TranscodeTask::getId, candidate.getId())
|
||||
.eq(TranscodeTask::getStatus, STATUS_QUEUED)
|
||||
.gt(TranscodeTask::getEpisodeId, 0)
|
||||
.set(TranscodeTask::getStatus, STATUS_RUNNING)
|
||||
.set(TranscodeTask::getStartedAt, LocalDateTime.now()));
|
||||
return updated == 1 ? candidate : null;
|
||||
@@ -101,14 +145,21 @@ public class TranscodeService {
|
||||
rendition.setStatus("ready");
|
||||
mediaRenditionMapper.insert(rendition);
|
||||
}
|
||||
String thumbnailError = "";
|
||||
if (thumbnail != null) {
|
||||
upsertThumbnail(task, thumbnail);
|
||||
try {
|
||||
thumbnailRecordService.upsert(task, thumbnail);
|
||||
thumbnailError = thumbnail.getErrorMsg() == null
|
||||
? "" : thumbnail.getErrorMsg();
|
||||
} catch (Exception ex) {
|
||||
thumbnailError = "缩略图登记失败:" + exceptionMessage(ex);
|
||||
log.warn("缩略图登记失败 task={}:{}", taskId, ex.getMessage());
|
||||
}
|
||||
}
|
||||
task.setStatus(STATUS_SUCCESS);
|
||||
task.setProgress(100);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
task.setErrorMsg(thumbnail == null || thumbnail.getErrorMsg() == null
|
||||
? "" : thumbnail.getErrorMsg());
|
||||
task.setErrorMsg(thumbnailError);
|
||||
transcodeTaskMapper.updateById(task);
|
||||
}
|
||||
|
||||
@@ -195,22 +246,34 @@ public class TranscodeService {
|
||||
.eq(TranscodeTask::getStatus, status));
|
||||
}
|
||||
|
||||
private void upsertThumbnail(TranscodeTask task, MediaThumbnail thumbnail) {
|
||||
if (task.getEpisodeId() == null || task.getEpisodeId() <= 0) {
|
||||
private void updateTaskBinding(TranscodeTask task, Long dramaId,
|
||||
Long episodeId, String sourceUrl) {
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
MediaThumbnail existing = mediaThumbnailMapper.selectOne(
|
||||
Wrappers.<MediaThumbnail>lambdaQuery()
|
||||
.eq(MediaThumbnail::getEpisodeId, task.getEpisodeId())
|
||||
.last("limit 1"));
|
||||
thumbnail.setDramaId(task.getDramaId() == null ? 0L : task.getDramaId());
|
||||
thumbnail.setEpisodeId(task.getEpisodeId());
|
||||
if (existing == null) {
|
||||
mediaThumbnailMapper.insert(thumbnail);
|
||||
return;
|
||||
boolean hasDrama = dramaId != null && dramaId > 0;
|
||||
boolean hasEpisode = episodeId != null && episodeId > 0;
|
||||
transcodeTaskMapper.update(null, Wrappers.<TranscodeTask>lambdaUpdate()
|
||||
.eq(TranscodeTask::getId, task.getId())
|
||||
.set(hasDrama, TranscodeTask::getDramaId, dramaId)
|
||||
.set(hasEpisode, TranscodeTask::getEpisodeId, episodeId)
|
||||
.set(sourceUrl != null && !sourceUrl.isBlank(),
|
||||
TranscodeTask::getSourceUrl, sourceUrl));
|
||||
if (hasDrama) {
|
||||
task.setDramaId(dramaId);
|
||||
}
|
||||
thumbnail.setId(existing.getId());
|
||||
mediaThumbnailMapper.updateById(thumbnail);
|
||||
if (hasEpisode) {
|
||||
task.setEpisodeId(episodeId);
|
||||
}
|
||||
if (sourceUrl != null && !sourceUrl.isBlank()) {
|
||||
task.setSourceUrl(sourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
private String exceptionMessage(Exception exception) {
|
||||
String message = exception.getMessage();
|
||||
return message == null || message.isBlank()
|
||||
? exception.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
private TranscodeTask getExisting(Long id) {
|
||||
|
||||
+3
-3
@@ -60,15 +60,15 @@ public class FfmpegThumbnailGenerator {
|
||||
}
|
||||
ThumbnailVttBuilder.Layout layout = new ThumbnailVttBuilder.Layout(
|
||||
durationSec, sprite.intervalSeconds(), sprite.columns(), rows,
|
||||
image.getWidth(), image.getHeight());
|
||||
sprite.thumbWidth(), image.getHeight() / rows);
|
||||
Path vtt = thumbnailDir.resolve(VTT_FILE);
|
||||
Files.writeString(vtt, ThumbnailVttBuilder.build(layout), StandardCharsets.UTF_8);
|
||||
|
||||
return new TranscodeRunner.ProducedThumbnail(
|
||||
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/" + VTT_FILE),
|
||||
joinKey(request.objectKeyPrefix(), THUMBNAIL_DIR + "/sprite_"),
|
||||
sprite.intervalSeconds(), sprite.columns(), rows, image.getWidth(),
|
||||
image.getHeight(), frames, durationSec, sizeOf(thumbnailDir),
|
||||
sprite.intervalSeconds(), sprite.columns(), rows, sprite.thumbWidth(),
|
||||
image.getHeight() / rows, frames, durationSec, sizeOf(thumbnailDir),
|
||||
STATUS_READY, "");
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -8,6 +8,7 @@ import com.fruitshorts.media.dal.entity.TranscodeTask;
|
||||
import com.fruitshorts.media.service.MediaService;
|
||||
import com.fruitshorts.media.service.MediaStorageService;
|
||||
import com.fruitshorts.media.service.MediaTranscodeProfileService;
|
||||
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
|
||||
import com.fruitshorts.media.service.TranscodeService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -42,6 +43,7 @@ public class TranscodeWorker {
|
||||
private final MediaStorageService storageService;
|
||||
private final TranscodeRunner transcodeRunner;
|
||||
private final MediaTranscodeProfileService profileService;
|
||||
private final EpisodeTranscodeCompletionPort episodeCompletionPort;
|
||||
|
||||
/** 定时轮询:队列空就按 poll-delay 再来一轮;上一轮没跑完不会被下一轮叠加(fixedDelay)。 */
|
||||
@Scheduled(fixedDelayString = "${fruitshorts.media.transcode.poll-delay-ms:5000}")
|
||||
@@ -90,6 +92,12 @@ public class TranscodeWorker {
|
||||
storageService.toPublicUrl(result.posterObjectKey()),
|
||||
result.durationSec());
|
||||
}
|
||||
if (task.getEpisodeId() != null && task.getEpisodeId() > 0) {
|
||||
episodeCompletionPort.markEpisodeReady(
|
||||
task.getEpisodeId(),
|
||||
storageService.toPublicUrl(result.masterObjectKey()),
|
||||
result.durationSec());
|
||||
}
|
||||
log.info("转码成功 task={} asset={} 档数={} 耗时={}ms 产物前缀={}",
|
||||
task.getId(), task.getMediaAssetId(), result.renditions().size(),
|
||||
System.currentTimeMillis() - startedAt, objectKeyPrefix);
|
||||
|
||||
Reference in new issue
Block a user