diff --git a/README.md b/README.md index 82f6801..3125d22 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ FruitServer/ | JDK | OpenJDK 21.0.12.1 | `brew install openjdk@21`,`JAVA_HOME` 已写入 `~/.zshrc` | | Maven | 3.10.0 | `brew install maven`,命令为 `/opt/homebrew/bin/mvn` | | PostgreSQL | 18.6 | `brew install postgresql@18`,`brew services start postgresql@18` 启动 | +| ffmpeg | 9.0.2(Homebrew 当前稳定版) | `brew install ffmpeg`;**只有转码 worker 需要**,两个在线入口(8080/8081)不依赖它 | > `postgresql@18` 是 keg-only,命令行工具在 `/opt/homebrew/opt/postgresql@18/bin`, > 用之前把它加到 PATH:`export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"`。 @@ -51,8 +52,13 @@ FruitServer/ ```sh java -version # openjdk 21.0.12.1 mvn -v # Maven 3.10.0 / Java 21.0.12.1 +ffmpeg -version # 转码 worker 用;没装也能跑在线服务,只是转码任务会一直排队 ``` +> **ffmpeg 只被 `fruitshorts-transcode-worker` 使用**:`backend-design-phase1.md` §8.5 要求转码与在线服务隔离, +> 所以 app-api / admin-api 里 `fruitshorts.media.transcode.worker-enabled` 默认 false,转码要单独起 worker 进程 +> (`java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar`)。 + 说明: - 工程编译目标为 Java 17(`maven.compiler.release=17`),在 JDK 21 上构建与运行。 diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java new file mode 100644 index 0000000..234117f --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/TranscodeProperties.java @@ -0,0 +1,75 @@ +// Author: fruit.com.cn + +package com.fruitshorts.media.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 转码 worker 配置。 + * + *
**默认关闭**(`worker-enabled=false`):`backend-design-phase1.md` §8.5 要求转码与在线服务隔离, + * 所以 app-api / admin-api 绝不跑转码;要跑就单独起 `fruitshorts-transcode-worker` 进程 + * (那个模块的 yml 里把开关置 true)。 + * + *
码率阶梯按 `backend-design-phase1.md` §3.1:360p/480p/720p/1080p。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "fruitshorts.media.transcode")
+public class TranscodeProperties {
+
+ /** 是否在本进程跑转码(默认 false,只有独立 worker 进程开)。 */
+ private boolean workerEnabled = false;
+
+ /** 队列空时的轮询间隔(毫秒)。 */
+ private long pollDelayMs = 5000L;
+
+ /** ffmpeg 可执行文件(生产建议绝对路径或容器内固定路径)。 */
+ private String ffmpegPath = "ffmpeg";
+
+ /** ffprobe 可执行文件,用于读时长。 */
+ private String ffprobePath = "ffprobe";
+
+ /** HLS 分片时长(秒)。 */
+ private int segmentSeconds = 6;
+
+ /** 封面截图取视频第几秒。 */
+ private int posterSecond = 1;
+
+ /** 单条 ffmpeg 的超时(秒):防止坏片源把 worker 卡死。 */
+ private int timeoutSeconds = 1800;
+
+ /** 码率阶梯(默认四档,改这里不用改代码)。 */
+ private List 由转码 worker 调用(worker 与上传在不同进程),所以这里是独立的小事务方法。
+ */
+ @Transactional(rollbackFor = Exception.class)
+ public void markTranscoding(Long assetId) {
+ updateAssetStatus(assetId, STATUS_TRANSCODING, TranscodeService.STATUS_RUNNING);
+ }
+
+ /** 转码成功:写 hls_url / poster_url 并把状态推到 ready。 */
+ @Transactional(rollbackFor = Exception.class)
+ public void markReady(Long assetId, String hlsUrl, String posterUrl, Integer durationSec) {
+ MediaAsset asset = assetId == null ? null : mediaAssetMapper.selectById(assetId);
+ if (asset == null) {
+ return;
+ }
+ asset.setStatus(STATUS_READY);
+ asset.setTranscodeStatus(TranscodeService.STATUS_SUCCESS);
+ asset.setHlsUrl(hlsUrl == null ? "" : hlsUrl);
+ asset.setPosterUrl(posterUrl == null ? "" : posterUrl);
+ if (durationSec != null && durationSec > 0) {
+ asset.setDurationSec(durationSec);
+ }
+ mediaAssetMapper.updateById(asset);
+ }
+
+ /** 转码最终失败(重试耗尽):状态置 failed,原因留给运营在后台排查。 */
+ @Transactional(rollbackFor = Exception.class)
+ public void markFailed(Long assetId, String reason) {
+ MediaAsset asset = assetId == null ? null : mediaAssetMapper.selectById(assetId);
+ if (asset == null) {
+ return;
+ }
+ asset.setStatus(STATUS_FAILED);
+ asset.setTranscodeStatus(TranscodeService.STATUS_FAILED);
+ mediaAssetMapper.updateById(asset);
+ }
+
+ private void updateAssetStatus(Long assetId, String status, String transcodeStatus) {
+ MediaAsset asset = assetId == null ? null : mediaAssetMapper.selectById(assetId);
+ if (asset == null) {
+ return;
+ }
+ asset.setStatus(status);
+ asset.setTranscodeStatus(transcodeStatus);
+ mediaAssetMapper.updateById(asset);
+ }
+
public MediaAssetRespVO get(Long id) {
MediaAsset asset = id == null ? null : mediaAssetMapper.selectById(id);
if (asset == null) {
diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaStorageService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaStorageService.java
index 4c57671..55ccb94 100644
--- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaStorageService.java
+++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaStorageService.java
@@ -53,6 +53,44 @@ public class MediaStorageService {
return root;
}
+ /**
+ * 把 objectKey 解析成**本地可写路径**:转码产物落地用它,避免各处在外面自己拼 `./data/uploads`。
+ *
+ * 切 OSS 时这里是唯一的替换点(改成"下载到临时目录 / 上传回对象存储"的适配)。
+ * 同时做一次越权校验:objectKey 不允许跑出根目录。
+ */
+ public Path fileFor(String objectKey) {
+ if (objectKey == null || objectKey.isBlank()) {
+ throw new ServiceException(ErrorCode.BAD_REQUEST, "objectKey 不能为空");
+ }
+ Path root = rootDir().toAbsolutePath().normalize();
+ Path target = root.resolve(objectKey.replaceAll("^/+", "")).normalize();
+ if (!target.startsWith(root)) {
+ throw new ServiceException(ErrorCode.BAD_REQUEST, "非法的 objectKey:" + objectKey);
+ }
+ return target;
+ }
+
+ /**
+ * 把对外 URL 反解成 objectKey(`/files/xxx/yyy.mp4` → `xxx/yyy.mp4`,顺带去掉签名查询串)。
+ * worker 拿到的 `transcode_task.source_url` 就是这种对外 URL。
+ */
+ public String objectKeyOf(String publicUrl) {
+ if (publicUrl == null || publicUrl.isBlank()) {
+ return "";
+ }
+ String clean = publicUrl;
+ int query = clean.indexOf('?');
+ if (query >= 0) {
+ clean = clean.substring(0, query);
+ }
+ String prefix = properties.getPublicPrefix();
+ if (clean.startsWith(prefix)) {
+ clean = clean.substring(prefix.length());
+ }
+ return clean.replaceAll("^/+", "");
+ }
+
public Path chunkDir(String uploadId) {
return rootDir().resolve("tmp").resolve(uploadId);
}
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 d81aabd..b2282dd 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
@@ -33,7 +33,8 @@ public class TranscodeService {
public static final String STATUS_RUNNING = "RUNNING";
public static final String STATUS_SUCCESS = "SUCCESS";
public static final String STATUS_FAILED = "FAILED";
- private static final int MAX_RETRY = 3;
+ /** 失败重试上限(`backend-design-phase1.md` §7.2:失败重试 3 次并记录原因)。 */
+ public static final int MAX_RETRY = 3;
private final TranscodeTaskMapper transcodeTaskMapper;
private final MediaRenditionMapper mediaRenditionMapper;
@@ -95,8 +96,13 @@ public class TranscodeService {
transcodeTaskMapper.updateById(task);
}
- /** 失败重试最多 3 次,超过则停在 FAILED,由运营在后台看到并处理。 */
- public void fail(Long taskId, String errorMessage) {
+ /**
+ * 失败重试最多 3 次,超过则停在 FAILED,由运营在后台看到并处理。
+ *
+ * @return 这次失败之后任务的新状态(QUEUED = 还会重试;FAILED = 已放弃),
+ * 调用方据此决定要不要把媒资也置为 failed
+ */
+ public String fail(Long taskId, String errorMessage) {
TranscodeTask task = getExisting(taskId);
int retry = task.getRetryCount() == null ? 0 : task.getRetryCount();
if (retry + 1 >= MAX_RETRY) {
@@ -108,6 +114,7 @@ public class TranscodeService {
task.setErrorMsg(errorMessage == null ? "" : errorMessage);
task.setFinishedAt(LocalDateTime.now());
transcodeTaskMapper.updateById(task);
+ return task.getStatus();
}
public List 每档码率一条 ffmpeg 命令出 VOD 型 HLS 到 `<输出目录>/ **注意**:本实现需要"本地可写路径",也就是本地磁盘存储形态;
+ * 将来切 OSS 时,这一层要改成"下载到临时目录 → 转码 → 上传",替换点就是 {@code MediaStorageService} 与这里。
+ */
+@Slf4j
+@Component
+public class FfmpegTranscodeRunner implements TranscodeRunner {
+
+ private static final String MASTER_PLAYLIST = "master.m3u8";
+ private static final String VARIANT_PLAYLIST = "index.m3u8";
+ private static final String POSTER_FILE = "poster.jpg";
+ private static final int STDERR_TAIL = 800;
+
+ @Override
+ public TranscodeResult run(TranscodeRequest request) {
+ if (!Files.isReadable(request.sourceFile())) {
+ throw new ServiceException(ErrorCode.DATA_NOT_FOUND,
+ "源文件不存在或不可读:" + request.sourceFile());
+ }
+ createDirectories(request.outputDir());
+ int durationSec = probeDuration(request);
+
+ List 输出**重定向到临时文件**而不是直接读管道:直接 `readAllBytes()` 会阻塞到进程结束,
+ * 那样 `waitFor(timeout)` 的超时就形同虚设,坏片源能把 worker 永久卡住。
+ */
+ private String runProcess(List 这样做的好处是 worker 的逻辑(取任务 / 登记产物 / 推状态 / 重试)可以**不依赖 ffmpeg** 做单测,
+ * 生产实现见 {@link FfmpegTranscodeRunner}。
+ */
+public interface TranscodeRunner {
+
+ /** 执行一次转码。失败时抛异常(异常消息会被写进 `transcode_task.error_msg`)。 */
+ TranscodeResult run(TranscodeRequest request);
+
+ /** 一档码率。 */
+ record LadderRung(String quality, int width, int height, int videoBitrateKbps, int audioBitrateKbps) {
+ }
+
+ /**
+ * 一次转码请求。
+ *
+ * @param sourceFile 源视频的**本地路径**(由 MediaStorageService 解析,worker 不自己拼路径)
+ * @param outputDir 产物落地的本地目录
+ * @param objectKeyPrefix 产物 objectKey 前缀,返回值里的 objectKey 基于它拼
+ */
+ record TranscodeRequest(Long taskId, Path sourceFile, Path outputDir, String objectKeyPrefix,
+ List 跑法:**默认不在 app-api / admin-api 里跑**(`fruitshorts.media.transcode.worker-enabled=false`),
+ * 要转码就单独起 `fruitshorts-transcode-worker`(`backend-design-phase1.md` §8.5 要求队列与在线服务隔离)。
+ *
+ * 并发安全靠 `TranscodeService.claimNext()` 的条件更新(`status=QUEUED` 才会被改成 `RUNNING`),
+ * 多进程同时抢也只有一个能拿到;拿不到就下一轮再来。
+ *
+ * 失败重试 3 次(`TranscodeService.fail`),每次都把原因写进 `error_msg`;
+ * 到第三次才落到 `FAILED`,同时把 `media_asset.status` 置 `failed`。
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+@ConditionalOnProperty(name = "fruitshorts.media.transcode.worker-enabled", havingValue = "true")
+public class TranscodeWorker {
+
+ private final TranscodeService transcodeService;
+ private final MediaService mediaService;
+ private final MediaStorageService storageService;
+ private final TranscodeRunner transcodeRunner;
+ private final TranscodeProperties properties;
+
+ /** 定时轮询:队列空就按 poll-delay 再来一轮;上一轮没跑完不会被下一轮叠加(fixedDelay)。 */
+ @Scheduled(fixedDelayString = "${fruitshorts.media.transcode.poll-delay-ms:5000}")
+ public void poll() {
+ try {
+ processOne();
+ } catch (Exception ex) {
+ // 轮询里不能让异常逃出去,否则调度线程会被取消,整个 worker 静默停摆
+ log.error("转码轮询异常:{}", ex.getMessage(), ex);
+ }
+ }
+
+ /**
+ * 处理一条任务。
+ *
+ * @return true 表示确实领到并处理了一条;false 表示队列空(或没抢到)
+ */
+ public boolean processOne() {
+ TranscodeTask task = transcodeService.claimNext();
+ if (task == null) {
+ return false;
+ }
+ process(task);
+ return true;
+ }
+
+ /** 处理指定任务:转码成功 → 登记产物 + 资产就绪;失败 → 记原因并决定是否重试。 */
+ public void process(TranscodeTask task) {
+ long startedAt = System.currentTimeMillis();
+ String objectKeyPrefix = outputPrefix(task);
+ try {
+ Path sourceFile = storageService.fileFor(storageService.objectKeyOf(task.getSourceUrl()));
+ Path outputDir = storageService.fileFor(objectKeyPrefix);
+ TranscodeRunner.TranscodeResult result = transcodeRunner.run(new TranscodeRunner.TranscodeRequest(
+ task.getId(), sourceFile, outputDir, objectKeyPrefix, ladder(), properties.getFfmpegPath(),
+ properties.getFfprobePath(), properties.getSegmentSeconds(), properties.getPosterSecond(),
+ properties.getTimeoutSeconds()));
+
+ transcodeService.succeed(task.getId(), toRenditions(result));
+ if (task.getMediaAssetId() != null && task.getMediaAssetId() > 0) {
+ mediaService.markReady(task.getMediaAssetId(),
+ storageService.toPublicUrl(result.masterObjectKey()),
+ storageService.toPublicUrl(result.posterObjectKey()),
+ result.durationSec());
+ }
+ log.info("转码成功 task={} asset={} 档数={} 耗时={}ms 产物前缀={}",
+ task.getId(), task.getMediaAssetId(), result.renditions().size(),
+ System.currentTimeMillis() - startedAt, objectKeyPrefix);
+ } catch (Exception ex) {
+ String reason = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
+ int attempt = (task.getRetryCount() == null ? 0 : task.getRetryCount()) + 1;
+ String status = transcodeService.fail(task.getId(), reason);
+ if (TranscodeService.STATUS_FAILED.equals(status)
+ && task.getMediaAssetId() != null && task.getMediaAssetId() > 0) {
+ mediaService.markFailed(task.getMediaAssetId(), reason);
+ }
+ log.error("转码失败 task={} 第 {}/{} 次尝试 耗时={}ms 结果={} 原因={}",
+ task.getId(), attempt, TranscodeService.MAX_RETRY, System.currentTimeMillis() - startedAt,
+ status, reason);
+ }
+ }
+
+ /** 产物 objectKey 前缀:`episode/transcoded/{taskId}`,与上传目录分开放,便于按前缀清理。 */
+ private String outputPrefix(TranscodeTask task) {
+ return "transcoded/" + task.getId();
+ }
+
+ private List 为什么单独一个进程:`backend-design-phase1.md` §8.5 要求"转码队列与在线服务隔离,避免抢占资源"。
+ * app-api / admin-api 里的 `fruitshorts.media.transcode.worker-enabled` 保持 false,
+ * 只有本进程把它打开,所以**跑不跑转码不影响 /app/** 的联调与播放**。
+ *
+ * 启动:`java -jar fruitshorts-transcode-worker/target/fruitshorts-transcode-worker-1.0.0-SNAPSHOT.jar`
+ * (需要 ffmpeg / ffprobe 在 PATH 里,或用 `fruitshorts.media.transcode.ffmpeg-path` 指定绝对路径)。
+ */
+@SpringBootApplication(scanBasePackages = "com.fruitshorts")
+@MapperScan("com.fruitshorts.**.dal.mapper")
+public class TranscodeWorkerApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(TranscodeWorkerApplication.class, args);
+ }
+}
diff --git a/fruitshorts-transcode-worker/src/main/resources/application.yml b/fruitshorts-transcode-worker/src/main/resources/application.yml
new file mode 100644
index 0000000..d66b8dd
--- /dev/null
+++ b/fruitshorts-transcode-worker/src/main/resources/application.yml
@@ -0,0 +1,51 @@
+# Author: fruit.com.cn
+#
+# 转码 worker 独立进程:只做队列消费,不对外提供接口。
+# 端口用 0(随机)——本进程不需要被访问,也不会跟 8080/8081 抢端口。
+server:
+ port: 0
+
+spring:
+ application:
+ name: fruitshorts-transcode-worker
+ autoconfigure:
+ exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
+ datasource:
+ driver-class-name: org.postgresql.Driver
+ url: jdbc:postgresql://127.0.0.1:5432/fruitshorts
+ username: ${DB_USER:fruitshorts}
+ password: ${DB_PASSWORD:fruitshorts}
+
+mybatis-plus:
+ configuration:
+ map-underscore-to-camel-case: true
+ global-config:
+ banner: false
+ db-config:
+ # 与两个入口保持一致:主键由应用侧雪花生成(S0),否则 worker 插入 media_rendition 会没有 id
+ id-type: assign_id
+ logic-delete-field: deleted
+ logic-delete-value: 1
+ logic-not-delete-value: 0
+ sequence:
+ # 单机固定值;多实例部署前改成按实例分配
+ worker-id: 2
+ datacenter-id: 1
+
+fruitshorts:
+ media:
+ # 必须与 app-api/admin-api 指向同一个存储根,worker 才找得到上传后的源文件
+ root-dir: ./data/uploads
+ public-prefix: /files
+ transcode:
+ # 只有本进程打开转码(§8.5 隔离要求)
+ worker-enabled: true
+ poll-delay-ms: 3000
+ ffmpeg-path: ${FFMPEG_PATH:ffmpeg}
+ ffprobe-path: ${FFPROBE_PATH:ffprobe}
+ segment-seconds: 6
+ poster-second: 1
+
+logging:
+ level:
+ com.fruitshorts: info
diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/FfmpegTranscodeRunnerSmokeTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/FfmpegTranscodeRunnerSmokeTest.java
new file mode 100644
index 0000000..1534f38
--- /dev/null
+++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/FfmpegTranscodeRunnerSmokeTest.java
@@ -0,0 +1,117 @@
+// Author: fruit.com.cn
+
+package com.fruitshorts.worker;
+
+import com.fruitshorts.media.service.transcode.FfmpegTranscodeRunner;
+import com.fruitshorts.media.service.transcode.TranscodeRunner;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * 真实 ffmpeg smoke:**自己生成 2 秒测试片**(不依赖外部素材),跑一遍真转码,验证 HLS + 封面确实产出。
+ *
+ * 本机没有 ffmpeg 时用 {@code assumeTrue} 跳过 —— 在 surefire 里会显示成 **Skipped**(不是通过),
+ * 回执里按实际情况说明,不把"跳过"当"验过"。
+ */
+class FfmpegTranscodeRunnerSmokeTest {
+
+ @Test
+ void should_produce_hls_and_poster_when_a_tiny_sample_is_transcoded() throws Exception {
+ String ffmpeg = binaryPath("ffmpeg");
+ String ffprobe = binaryPath("ffprobe");
+ assumeTrue(ffmpeg != null && ffprobe != null,
+ "本机没有 ffmpeg/ffprobe,跳过真实转码 smoke");
+
+ Path work = Files.createTempDirectory("fruitshorts-transcode-smoke");
+ try {
+ Path sample = work.resolve("sample.mp4");
+ generateSample(ffmpeg, sample);
+ assertThat(Files.size(sample)).isGreaterThan(0L);
+
+ Path output = work.resolve("out");
+ TranscodeRunner runner = new FfmpegTranscodeRunner();
+ // 只跑两档(360p/480p)保持秒级;四档阶梯的逻辑在 TranscodeWorkerTest 里断言
+ TranscodeRunner.TranscodeResult result = runner.run(new TranscodeRunner.TranscodeRequest(
+ 1L, sample, output, "transcoded/smoke",
+ List.of(new TranscodeRunner.LadderRung("360p", 640, 360, 400, 64),
+ new TranscodeRunner.LadderRung("480p", 854, 480, 800, 64)),
+ ffmpeg, ffprobe, 2, 1, 120));
+
+ assertThat(Files.exists(output.resolve("master.m3u8"))).isTrue();
+ assertThat(Files.exists(output.resolve("poster.jpg"))).isTrue();
+ assertThat(Files.size(output.resolve("poster.jpg"))).isGreaterThan(0L);
+ assertThat(result.renditions()).hasSize(2);
+ for (TranscodeRunner.ProducedRendition rendition : result.renditions()) {
+ Path variant = output.resolve(rendition.quality()).resolve("index.m3u8");
+ assertThat(Files.exists(variant)).as("变体播放列表 %s", rendition.quality()).isTrue();
+ assertThat(countSegments(output.resolve(rendition.quality())))
+ .as("%s 分片数", rendition.quality()).isGreaterThan(0);
+ assertThat(rendition.sizeBytes()).isGreaterThan(0L);
+ }
+ assertThat(result.durationSec()).isBetween(1, 5);
+ assertThat(readLines(output.resolve("master.m3u8")))
+ .anyMatch(line -> line.contains("360p/index.m3u8"))
+ .anyMatch(line -> line.contains("480p/index.m3u8"));
+ } finally {
+ deleteRecursively(work);
+ }
+ }
+
+ private void generateSample(String ffmpeg, Path sample) throws Exception {
+ 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",
+ sample.toString())
+ .redirectErrorStream(true)
+ .start();
+ assertThat(process.waitFor(120, TimeUnit.SECONDS)).as("生成测试片超时").isTrue();
+ assertThat(process.exitValue()).as("生成测试片失败").isZero();
+ }
+
+ /** 在 PATH 里找可执行文件;找不到返回 null(由调用方 assume)。 */
+ private String binaryPath(String name) {
+ try {
+ Process process = new ProcessBuilder("which", name).redirectErrorStream(true).start();
+ String path = new String(process.getInputStream().readAllBytes()).trim();
+ process.waitFor(10, TimeUnit.SECONDS);
+ return process.exitValue() == 0 && !path.isEmpty() ? path : null;
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+
+ private int countSegments(Path dir) throws IOException {
+ try (Stream 用替身 `TranscodeRunner`,所以**不依赖 ffmpeg**;真实 ffmpeg 的验证在 ffmpeg smoke 测试里。
+ */
+class TranscodeWorkerTest {
+
+ private static final Long TASK_ID = 88L;
+ private static final Long ASSET_ID = 99L;
+
+ private TranscodeService transcodeService;
+ private MediaService mediaService;
+ private MediaStorageService storageService;
+ private StubRunner runner;
+ private TranscodeWorker worker;
+
+ @BeforeEach
+ void setUp() {
+ transcodeService = mock(TranscodeService.class);
+ mediaService = mock(MediaService.class);
+ storageService = mock(MediaStorageService.class);
+ runner = new StubRunner();
+ worker = new TranscodeWorker(transcodeService, mediaService, storageService, runner,
+ new TranscodeProperties());
+ given(storageService.objectKeyOf(anyString())).willReturn("episode/202610/1.mp4");
+ given(storageService.fileFor(anyString())).willReturn(Paths.get("/tmp/fruitshorts-test/1.mp4"));
+ given(storageService.toPublicUrl(anyString()))
+ .willAnswer(invocation -> "/files/" + invocation.getArgument(0));
+ }
+
+ @Test
+ void should_register_renditions_and_mark_asset_ready_when_transcode_succeeds() {
+ given(transcodeService.claimNext()).willReturn(queuedTask());
+
+ assertThat(worker.processOne()).isTrue();
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(transcodeService).succeed(eq(TASK_ID), captor.capture());
+ assertThat(captor.getValue()).hasSize(2);
+ // 对外地址一律经 MediaStorageService,worker 不自己拼磁盘路径
+ assertThat(captor.getValue().get(0).getPlayUrl()).startsWith("/files/transcoded/88/");
+ assertThat(captor.getValue().get(0).getFormat()).isEqualTo("HLS");
+ verify(mediaService).markReady(eq(ASSET_ID), eq("/files/transcoded/88/master.m3u8"),
+ eq("/files/transcoded/88/poster.jpg"), eq(120));
+ verify(transcodeService, never()).fail(any(), anyString());
+ }
+
+ @Test
+ void should_cover_all_ladder_rungs_when_requesting_transcode() {
+ given(transcodeService.claimNext()).willReturn(queuedTask());
+
+ worker.processOne();
+
+ // 默认阶梯就是 backend-design-phase1.md §3.1 的四档
+ assertThat(runner.request.ladder()).extracting(TranscodeRunner.LadderRung::quality)
+ .containsExactly("360p", "480p", "720p", "1080p");
+ assertThat(runner.request.objectKeyPrefix()).isEqualTo("transcoded/88");
+ }
+
+ @Test
+ void should_keep_task_queued_and_record_reason_when_transcode_fails_before_max_retry() {
+ TranscodeTask task = queuedTask();
+ task.setRetryCount(1);
+ given(transcodeService.claimNext()).willReturn(task);
+ given(transcodeService.fail(eq(TASK_ID), anyString())).willReturn(TranscodeService.STATUS_QUEUED);
+ runner.failure = new IllegalStateException("ffmpeg 退出码 1:Invalid data found");
+
+ worker.processOne();
+
+ verify(transcodeService).fail(eq(TASK_ID), contains("Invalid data found"));
+ // 还会重试,所以媒资不能置 failed(仍期待下一次成功)
+ verify(mediaService, never()).markFailed(any(), anyString());
+ verify(mediaService, never()).markReady(any(), anyString(), anyString(), any());
+ }
+
+ @Test
+ void should_mark_asset_failed_when_retries_are_exhausted() {
+ TranscodeTask task = queuedTask();
+ task.setRetryCount(2);
+ given(transcodeService.claimNext()).willReturn(task);
+ given(transcodeService.fail(eq(TASK_ID), anyString())).willReturn(TranscodeService.STATUS_FAILED);
+ runner.failure = new IllegalStateException("ffmpeg 未产出播放列表");
+
+ worker.processOne();
+
+ verify(transcodeService).fail(eq(TASK_ID), contains("未产出播放列表"));
+ verify(mediaService).markFailed(eq(ASSET_ID), contains("未产出播放列表"));
+ }
+
+ @Test
+ void should_do_nothing_when_no_task_can_be_claimed() {
+ // 另一个 worker 已经抢走(或队列为空):claimNext 返回 null
+ given(transcodeService.claimNext()).willReturn(null);
+
+ assertThat(worker.processOne()).isFalse();
+ assertThat(runner.called).isFalse();
+ verify(transcodeService, never()).succeed(any(), any());
+ verify(transcodeService, never()).fail(any(), anyString());
+ }
+
+ private TranscodeTask queuedTask() {
+ TranscodeTask task = new TranscodeTask();
+ task.setId(TASK_ID);
+ task.setMediaAssetId(ASSET_ID);
+ task.setDramaId(5L);
+ task.setEpisodeId(12L);
+ task.setSourceUrl("/files/episode/202610/1.mp4");
+ task.setStatus(TranscodeService.STATUS_RUNNING);
+ task.setRetryCount(0);
+ return task;
+ }
+
+ /** 替身转码器:记录请求,按需抛异常。 */
+ private static final class StubRunner implements TranscodeRunner {
+ private TranscodeRequest request;
+ private RuntimeException failure;
+ private boolean called;
+
+ @Override
+ public TranscodeResult run(TranscodeRequest request) {
+ this.request = request;
+ this.called = true;
+ if (failure != null) {
+ throw failure;
+ }
+ return new TranscodeResult(
+ request.objectKeyPrefix() + "/master.m3u8",
+ List.of(new ProducedRendition("360p", 640, 360, 400, 64,
+ request.objectKeyPrefix() + "/360p/index.m3u8", 1024L, 4),
+ new ProducedRendition("480p", 854, 480, 800, 64,
+ request.objectKeyPrefix() + "/480p/index.m3u8", 2048L, 4)),
+ request.objectKeyPrefix() + "/poster.jpg", 120);
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index 5139a1b..192eefb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -33,6 +33,8 @@