fix(media): 补齐分集转码绑定与产物回填

This commit is contained in:
Zhou Weidong committed 2026-10-22 12:03:58 +08:00
1 parent 6bdd245a83
commit 7d563f2cd6
18 files changed
+812 -25

No files matched your search

+4
View File
@@ -21,6 +21,10 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-media</artifactId>
</dependency>
<dependency>
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-content</artifactId>
</dependency>
<!-- 单测用(worker 的编排逻辑不依赖真实 ffmpeg) -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -0,0 +1,64 @@
// Author: fruit.com.cn
package com.fruitshorts.worker;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.EpisodeTranscodeBindingService;
import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.TranscodeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* 视频 URL -> media_asset -> transcode_task 的分集绑定。
*/
class EpisodeTranscodeBindingServiceTest {
private MediaAssetMapper mediaAssetMapper;
private MediaStorageService storageService;
private TranscodeService transcodeService;
private EpisodeTranscodeBindingService service;
@BeforeEach
void setUp() {
mediaAssetMapper = mock(MediaAssetMapper.class);
storageService = mock(MediaStorageService.class);
transcodeService = mock(TranscodeService.class);
service = new EpisodeTranscodeBindingService(
mediaAssetMapper, storageService, transcodeService);
}
@Test
void should_find_asset_by_object_key_and_bind_task_when_episode_saved() {
MediaAsset asset = new MediaAsset();
asset.setId(88L);
asset.setSourceUrl("/files/episode/202610/video.mp4");
given(storageService.objectKeyOf("/files/episode/202610/video.mp4?e=1&s=x"))
.willReturn("episode/202610/video.mp4");
given(mediaAssetMapper.selectOne(any())).willReturn(asset);
Long assetId = service.bind(
31L, 7L, "/files/episode/202610/video.mp4?e=1&s=x");
assertThat(assetId).isEqualTo(88L);
verify(transcodeService).bindToEpisode(
88L, 7L, 31L, "/files/episode/202610/video.mp4");
}
@Test
void should_return_null_when_video_url_has_no_uploaded_asset() {
given(storageService.objectKeyOf("https://external.test/video.mp4"))
.willReturn("https://external.test/video.mp4");
given(mediaAssetMapper.selectOne(any())).willReturn(null);
assertThat(service.bind(31L, 7L, "https://external.test/video.mp4"))
.isNull();
}
}
@@ -62,6 +62,8 @@ class FfmpegTranscodeRunnerSmokeTest {
assertThat(result.durationSec()).isBetween(1, 5);
assertThat(result.thumbnail()).isNotNull();
assertThat(result.thumbnail().status()).isEqualTo("READY");
assertThat(result.thumbnail().thumbWidth()).isEqualTo(160);
assertThat(result.thumbnail().thumbHeight()).isEqualTo(90);
assertThat(Files.exists(output.resolve("thumbnails/thumbnails.vtt"))).isTrue();
assertThat(Files.exists(output.resolve("thumbnails/sprite_001.jpg"))).isTrue();
assertThat(readLines(output.resolve("master.m3u8")))
@@ -76,7 +78,8 @@ class FfmpegTranscodeRunnerSmokeTest {
Process process = new ProcessBuilder(ffmpeg, "-y",
"-f", "lavfi", "-i", "testsrc=size=640x360:rate=15",
"-f", "lavfi", "-i", "sine=frequency=440:duration=2",
"-shortest", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
"-shortest", "-t", "2",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac",
sample.toString())
.redirectErrorStream(true)
.start();
@@ -3,6 +3,7 @@
package com.fruitshorts.worker;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.media.config.MediaProperties;
@@ -14,10 +15,12 @@ import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper;
import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper;
import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.media.service.ThumbnailRecordService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.nio.file.Path;
@@ -27,6 +30,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/**
@@ -61,8 +65,11 @@ class TranscodeClaimAndStorageTest {
transcodeTaskMapper = mock(TranscodeTaskMapper.class);
mediaRenditionMapper = mock(MediaRenditionMapper.class);
mediaThumbnailMapper = mock(MediaThumbnailMapper.class);
ThumbnailRecordService thumbnailRecordService =
new ThumbnailRecordService(mediaThumbnailMapper);
transcodeService = new TranscodeService(
transcodeTaskMapper, mediaRenditionMapper, mediaThumbnailMapper);
transcodeTaskMapper, mediaRenditionMapper,
mediaThumbnailMapper, thumbnailRecordService);
mediaProperties = new MediaProperties();
mediaProperties.setRootDir("/tmp/fruitshorts-storage-test");
mediaProperties.setPublicPrefix("/files");
@@ -89,6 +96,59 @@ class TranscodeClaimAndStorageTest {
assertThat(claimed.getId()).isEqualTo(2L);
}
@Test
void should_exclude_unbound_queued_task_from_claim_query() {
given(transcodeTaskMapper.selectOne(any())).willReturn(null);
assertThat(transcodeService.claimNext()).isNull();
@SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<TranscodeTask>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(transcodeTaskMapper).selectOne(captor.capture());
assertThat(captor.getValue().getSqlSegment()).contains("episode_id >");
}
@Test
void should_reuse_and_backfill_existing_task_when_episode_is_bound() {
TranscodeTask existing = task(4L);
existing.setMediaAssetId(88L);
existing.setEpisodeId(0L);
existing.setDramaId(0L);
given(transcodeTaskMapper.selectOne(any())).willReturn(existing);
Long taskId = transcodeService.bindToEpisode(
88L, 7L, 31L, "/files/episode/202610/video.mp4");
assertThat(taskId).isEqualTo(4L);
assertThat(existing.getDramaId()).isEqualTo(7L);
assertThat(existing.getEpisodeId()).isEqualTo(31L);
verify(transcodeTaskMapper).update(any(), any());
verify(transcodeTaskMapper, never()).insert(any(TranscodeTask.class));
}
@Test
void should_keep_main_success_when_thumbnail_record_transaction_fails() {
TranscodeTask task = task(5L);
task.setDramaId(7L);
task.setEpisodeId(31L);
given(transcodeTaskMapper.selectById(5L)).willReturn(task);
ThumbnailRecordService failingRecordService = mock(ThumbnailRecordService.class);
doThrow(new IllegalStateException("media_thumbnail does not exist"))
.when(failingRecordService).upsert(any(), any());
TranscodeService guardedService = new TranscodeService(
transcodeTaskMapper, mediaRenditionMapper,
mediaThumbnailMapper, failingRecordService);
MediaThumbnail thumbnail = new MediaThumbnail();
thumbnail.setEpisodeId(31L);
thumbnail.setStatus(MediaThumbnail.STATUS_READY);
guardedService.succeed(5L, java.util.List.of(), thumbnail);
assertThat(task.getStatus()).isEqualTo(TranscodeService.STATUS_SUCCESS);
assertThat(task.getErrorMsg()).contains("缩略图登记失败");
verify(transcodeTaskMapper).updateById(task);
}
@Test
void should_return_null_when_queue_is_empty() {
given(transcodeTaskMapper.selectOne(any())).willReturn(null);
@@ -143,6 +203,7 @@ class TranscodeClaimAndStorageTest {
task.setId(id);
task.setStatus(TranscodeService.STATUS_QUEUED);
task.setRetryCount(0);
task.setEpisodeId(11L);
return task;
}
}
@@ -9,6 +9,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 com.fruitshorts.media.service.transcode.TranscodeRunner;
import com.fruitshorts.media.service.transcode.TranscodeWorker;
@@ -44,6 +45,7 @@ class TranscodeWorkerTest {
private MediaStorageService storageService;
private StubRunner runner;
private MediaTranscodeProfileService profileService;
private EpisodeTranscodeCompletionPort episodeCompletionPort;
private TranscodeWorker worker;
@BeforeEach
@@ -52,9 +54,10 @@ class TranscodeWorkerTest {
mediaService = mock(MediaService.class);
storageService = mock(MediaStorageService.class);
profileService = mock(MediaTranscodeProfileService.class);
episodeCompletionPort = mock(EpisodeTranscodeCompletionPort.class);
runner = new StubRunner();
worker = new TranscodeWorker(transcodeService, mediaService, storageService, runner,
profileService);
profileService, episodeCompletionPort);
given(profileService.currentProfile()).willReturn(new MediaTranscodeProfile());
given(profileService.ffmpegPath()).willReturn("ffmpeg");
given(profileService.ffprobePath()).willReturn("ffprobe");
@@ -87,6 +90,8 @@ class TranscodeWorkerTest {
.isEqualTo(MediaThumbnail.STATUS_READY);
verify(mediaService).markReady(eq(ASSET_ID), eq("/files/transcoded/88/master.m3u8"),
eq("/files/transcoded/88/poster.jpg"), eq(120));
verify(episodeCompletionPort).markEpisodeReady(
12L, "/files/transcoded/88/master.m3u8", 120);
verify(transcodeService, never()).fail(any(), anyString());
}