Skip to content

Commit fa622ee

Browse files
committed
feat: Implement subtitle transcoding and video fingerprinting service
- Add SubtitleTranscodeConsumer to handle subtitle transcoding tasks from RabbitMQ - Implement VideoFingerprintService to detect duplicate videos and avoid re-transcoding - Create TranscodeJob entity, mapper and service for persistent job tracking and retries - Introduce DTOs: SubtitleTranscodeTask, SubtitleJobStatus, and CleanupTask - Add /transcode/check-fingerprint endpoint to query existing transcode jobs - Enhance MediaServiceController with Redis-based job status management and error handling
1 parent 42c4e77 commit fa622ee

23 files changed

+2053
-135
lines changed

src/main/java/cn/programcx/foxnaserver/api/media/MediaServiceController.java

Lines changed: 303 additions & 39 deletions
Large diffs are not rendered by default.
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
package cn.programcx.foxnaserver.api.media;
2+
3+
import cn.programcx.foxnaserver.common.Result;
4+
import cn.programcx.foxnaserver.dto.media.JobStatus;
5+
import cn.programcx.foxnaserver.entity.TranscodeJob;
6+
import cn.programcx.foxnaserver.service.media.TranscodeJobService;
7+
import cn.programcx.foxnaserver.service.media.VideoFingerprintService;
8+
import cn.programcx.foxnaserver.util.JwtUtil;
9+
import com.baomidou.mybatisplus.core.metadata.IPage;
10+
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.Parameter;
12+
import io.swagger.v3.oas.annotations.tags.Tag;
13+
import lombok.Data;
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
import org.springframework.data.redis.core.RedisTemplate;
17+
import org.springframework.web.bind.annotation.*;
18+
19+
import java.util.HashMap;
20+
import java.util.List;
21+
import java.util.Map;
22+
23+
/**
24+
* 转码任务管理控制器
25+
*/
26+
@Slf4j
27+
@RestController
28+
@RequestMapping("/api/transcode/jobs")
29+
@RequiredArgsConstructor
30+
@Tag(name = "TranscodeJobController", description = "转码任务管理接口")
31+
public class TranscodeJobController {
32+
33+
private final TranscodeJobService transcodeJobService;
34+
private final VideoFingerprintService fingerprintService;
35+
private final RedisTemplate<String, Object> redisTemplate;
36+
37+
/**
38+
* 获取当前用户ID (UUID string)
39+
*/
40+
private String getCurrentUserId() {
41+
String uuid = JwtUtil.getCurrentUuid();
42+
if (uuid == null || uuid.isEmpty()) {
43+
throw new RuntimeException("未登录或登录已过期");
44+
}
45+
return uuid;
46+
}
47+
48+
/**
49+
* 创建视频转码任务
50+
*/
51+
@Operation(summary = "创建视频转码任务", description = "创建一个新的视频转码任务")
52+
@PostMapping("/create")
53+
public Result<TranscodeJob> createJob(@RequestBody CreateJobRequest request) {
54+
try {
55+
String userId = getCurrentUserId();
56+
57+
// 如果没有提供指纹,生成一个
58+
String fingerprint = request.getFingerprint();
59+
if (fingerprint == null || fingerprint.isEmpty()) {
60+
fingerprint = fingerprintService.generateFingerprint(request.getVideoPath());
61+
}
62+
63+
// 检查是否已有相同的完成任务
64+
TranscodeJob existingJob = transcodeJobService.findCompletedByFingerprint(fingerprint);
65+
if (existingJob != null && existingJob.getCreatorId().equals(userId)) {
66+
log.info("用户 [{}] 请求转码已存在的视频 [{}],复用任务 [{}]",
67+
userId, request.getVideoPath(), existingJob.getJobId());
68+
return Result.success(existingJob, "该视频已转码完成,直接复用");
69+
}
70+
71+
TranscodeJob job = transcodeJobService.createVideoJob(
72+
userId,
73+
request.getVideoPath(),
74+
request.getAudioTrackIndex(),
75+
request.getSubtitleTrackIndex(),
76+
request.isImmediate(),
77+
fingerprint
78+
);
79+
80+
return Result.success(job);
81+
} catch (Exception e) {
82+
log.error("创建转码任务失败: {}", e.getMessage());
83+
return Result.error(500, "创建转码任务失败: " + e.getMessage());
84+
}
85+
}
86+
87+
/**
88+
* 检查指纹状态
89+
*/
90+
@Operation(summary = "检查视频指纹", description = "检查视频是否已转码过,避免重复转码")
91+
@GetMapping("/check-fingerprint")
92+
public Result<FingerprintCheckResult> checkFingerprint(
93+
@Parameter(description = "视频文件路径") @RequestParam String videoPath,
94+
@Parameter(description = "指纹(可选,不传则后端计算)") @RequestParam(required = false) String fingerprint) {
95+
try {
96+
String userId = getCurrentUserId();
97+
98+
if (fingerprint == null || fingerprint.isEmpty()) {
99+
fingerprint = fingerprintService.generateFingerprint(videoPath);
100+
}
101+
102+
// 检查用户是否已有该视频的任务
103+
TranscodeJob userJob = transcodeJobService.findCompletedByFingerprint(fingerprint);
104+
if (userJob != null && userJob.getCreatorId().equals(userId)) {
105+
return Result.success(new FingerprintCheckResult(true, userJob.getJobId(),
106+
fingerprint, userJob.getHlsPath(), userJob.getStatus()), "已存在可用的转码结果");
107+
}
108+
109+
// 检查是否有进行中的任务
110+
final String fp = fingerprint; // lambda需要final变量
111+
TranscodeJob processingJob = transcodeJobService.listAllJobsByCreator(userId).stream()
112+
.filter(j -> fp.equals(j.getFingerprint()))
113+
.filter(j -> TranscodeJob.Status.PENDING.name().equals(j.getStatus()) ||
114+
TranscodeJob.Status.PROCESSING.name().equals(j.getStatus()))
115+
.findFirst()
116+
.orElse(null);
117+
118+
if (processingJob != null) {
119+
return Result.success(new FingerprintCheckResult(false, processingJob.getJobId(),
120+
fingerprint, null, processingJob.getStatus()), "转码任务进行中");
121+
}
122+
123+
return Result.success(new FingerprintCheckResult(false, null, fingerprint, null, null),
124+
"未找到转码记录,需要新建任务");
125+
126+
} catch (Exception e) {
127+
log.error("检查指纹失败: {}", e.getMessage());
128+
return Result.error(500, "检查指纹失败: " + e.getMessage());
129+
}
130+
}
131+
132+
/**
133+
* 查询任务详情
134+
*/
135+
@Operation(summary = "查询任务详情", description = "根据任务ID查询转码任务详情")
136+
@GetMapping("/{jobId}")
137+
public Result<TranscodeJob> getJobDetail(
138+
@Parameter(description = "任务ID") @PathVariable String jobId) {
139+
try {
140+
String userId = getCurrentUserId();
141+
TranscodeJob job = transcodeJobService.getByJobIdAndCreator(jobId, userId);
142+
143+
if (job == null) {
144+
return Result.error(404, "任务不存在或无权限访问");
145+
}
146+
JobStatus jobStatus = (JobStatus) redisTemplate.opsForValue().get("job:" + jobId);
147+
if (jobStatus != null) {
148+
job.setProgress(jobStatus.getProgress());
149+
job.setTotalStages(jobStatus.getStages());
150+
job.setCurrentStage(jobStatus.getCurrentStage());
151+
}
152+
//
153+
return Result.success(job);
154+
} catch (Exception e) {
155+
log.error("查询任务详情失败: {}", e.getMessage());
156+
return Result.error(500, "查询任务详情失败: " + e.getMessage());
157+
}
158+
}
159+
160+
/**
161+
* 分页查询任务列表
162+
*/
163+
@Operation(summary = "查询任务列表", description = "分页查询当前用户的转码任务列表")
164+
@GetMapping("/list")
165+
public Result<IPage<TranscodeJob>> listJobs(
166+
@Parameter(description = "页码,默认1") @RequestParam(defaultValue = "1") int page,
167+
@Parameter(description = "每页大小,默认10") @RequestParam(defaultValue = "10") int size) {
168+
try {
169+
String userId = getCurrentUserId();
170+
IPage<TranscodeJob> jobs = transcodeJobService.listJobsByCreator(userId, page, size);
171+
// 从Redis获取任务状态
172+
for (TranscodeJob job : jobs.getRecords()) {
173+
JobStatus jobStatus = (JobStatus) redisTemplate.opsForValue().get("job:" + job.getJobId());
174+
if (jobStatus != null) {
175+
job.setProgress(jobStatus.getProgress());
176+
job.setTotalStages(jobStatus.getStages());
177+
job.setCurrentStage(jobStatus.getCurrentStage());
178+
}
179+
}
180+
return Result.success(jobs);
181+
} catch (Exception e) {
182+
log.error("查询任务列表失败: {}", e.getMessage());
183+
return Result.error(500, "查询任务列表失败: " + e.getMessage());
184+
}
185+
}
186+
187+
/**
188+
* 查询所有任务(不分页)
189+
*/
190+
@Operation(summary = "查询所有任务", description = "查询当前用户的所有转码任务")
191+
@GetMapping("/list-all")
192+
public Result<List<TranscodeJob>> listAllJobs() {
193+
try {
194+
String userId = getCurrentUserId();
195+
List<TranscodeJob> jobs = transcodeJobService.listAllJobsByCreator(userId);
196+
for (TranscodeJob job : jobs) {
197+
JobStatus jobStatus = (JobStatus) redisTemplate.opsForValue().get("job:" + job.getJobId());
198+
if (jobStatus != null) {
199+
job.setProgress(jobStatus.getProgress());
200+
job.setTotalStages(jobStatus.getStages());
201+
job.setCurrentStage(jobStatus.getCurrentStage());
202+
}
203+
}
204+
return Result.success(jobs);
205+
} catch (Exception e) {
206+
log.error("查询任务列表失败: {}", e.getMessage());
207+
return Result.error(500, "查询任务列表失败: " + e.getMessage());
208+
}
209+
}
210+
211+
/**
212+
* 停止任务
213+
*/
214+
@Operation(summary = "停止任务", description = "停止进行中的转码任务")
215+
@PostMapping("/{jobId}/stop")
216+
public Result<String> stopJob(
217+
@Parameter(description = "任务ID") @PathVariable String jobId) {
218+
try {
219+
String userId = getCurrentUserId();
220+
boolean success = transcodeJobService.stopJob(jobId, userId);
221+
if (success) {
222+
return Result.success(null, "任务已停止");
223+
} else {
224+
return Result.error(400, "任务不存在或无法停止");
225+
}
226+
} catch (Exception e) {
227+
log.error("停止任务失败: {}", e.getMessage());
228+
return Result.error(500, "停止任务失败: " + e.getMessage());
229+
}
230+
}
231+
232+
/**
233+
* 重试任务
234+
*/
235+
@Operation(summary = "重试任务", description = "重试失败或已取消的转码任务")
236+
@PostMapping("/{jobId}/retry")
237+
public Result<String> retryJob(
238+
@Parameter(description = "任务ID") @PathVariable String jobId) {
239+
try {
240+
String userId = getCurrentUserId();
241+
boolean success = transcodeJobService.retryJob(jobId, userId);
242+
if (success) {
243+
return Result.success(null, "任务已重新提交");
244+
} else {
245+
return Result.error(400, "任务不存在或无法重试");
246+
}
247+
} catch (Exception e) {
248+
log.error("重试任务失败: {}", e.getMessage());
249+
return Result.error(500, "重试任务失败: " + e.getMessage());
250+
}
251+
}
252+
253+
/**
254+
* 删除任务
255+
*/
256+
@Operation(summary = "删除任务", description = "删除转码任务及其相关文件")
257+
@DeleteMapping("/{jobId}")
258+
public Result<String> deleteJob(
259+
@Parameter(description = "任务ID") @PathVariable String jobId) {
260+
try {
261+
String userId = getCurrentUserId();
262+
boolean success = transcodeJobService.deleteJob(jobId, userId);
263+
if (success) {
264+
return Result.success(null, "任务已删除");
265+
} else {
266+
return Result.error(404, "任务不存在或无权限删除");
267+
}
268+
} catch (Exception e) {
269+
log.error("删除任务失败: {}", e.getMessage());
270+
return Result.error(500, "删除任务失败: " + e.getMessage());
271+
}
272+
}
273+
274+
/**
275+
* 批量删除所有任务
276+
*/
277+
@Operation(summary = "批量删除所有任务", description = "删除当前用户的所有转码任务")
278+
@DeleteMapping("/delete-all")
279+
public Result<String> deleteAllJobs() {
280+
try {
281+
String userId = getCurrentUserId();
282+
int count = transcodeJobService.deleteAllJobsByCreator(userId);
283+
return Result.success(null, "已删除 " + count + " 个任务");
284+
} catch (Exception e) {
285+
log.error("批量删除任务失败: {}", e.getMessage());
286+
return Result.error(500, "批量删除任务失败: " + e.getMessage());
287+
}
288+
}
289+
290+
/**
291+
* 获取任务统计
292+
*/
293+
@Operation(summary = "获取任务统计", description = "获取当前用户的转码任务统计信息")
294+
@GetMapping("/statistics")
295+
public Result<Map<String, Object>> getStatistics() {
296+
try {
297+
String userId = getCurrentUserId();
298+
Map<String, Object> stats = transcodeJobService.getStatistics(userId);
299+
return Result.success(stats);
300+
} catch (Exception e) {
301+
log.error("获取任务统计失败: {}", e.getMessage());
302+
return Result.error(500, "获取任务统计失败: " + e.getMessage());
303+
}
304+
}
305+
306+
/**
307+
* 获取任务实时进度(轮询)
308+
*/
309+
@Operation(summary = "获取任务实时进度", description = "轮询获取任务的实时进度")
310+
@GetMapping("/{jobId}/progress")
311+
public Result<Map<String,String>> getJobProgress(
312+
@Parameter(description = "任务ID") @PathVariable String jobId) {
313+
try {
314+
JobStatus jobStatus = (JobStatus) redisTemplate.opsForValue().get("job:" + jobId);
315+
if (jobStatus == null) {
316+
return Result.error(404, "任务不存在或进度未更新");
317+
}
318+
Map<String,String> progress = new HashMap<>();
319+
progress.put("progress", String.format("%.2f", jobStatus.getProgress()));
320+
progress.put("totalStages", String.valueOf(jobStatus.getStages()));
321+
progress.put("currentStage", String.valueOf(jobStatus.getCurrentStage()));
322+
progress.put("state", jobStatus.getState().name());
323+
return Result.success(progress);
324+
} catch (Exception e) {
325+
log.error("获取任务进度失败: {}", e.getMessage());
326+
return Result.error(500, "获取任务进度失败: " + e.getMessage());
327+
}
328+
}
329+
330+
331+
// ==================== 请求/响应 DTO ====================
332+
333+
@Data
334+
public static class CreateJobRequest {
335+
private String videoPath;
336+
private Integer audioTrackIndex = 0;
337+
private Integer subtitleTrackIndex = -1;
338+
private boolean immediate = false;
339+
private String fingerprint; // 可选
340+
}
341+
342+
@Data
343+
public static class FingerprintCheckResult {
344+
private boolean existed; // 是否已存在可用结果
345+
private String jobId; // 任务ID
346+
private String fingerprint; // 指纹
347+
private String hlsPath; // HLS播放路径
348+
private String status; // 任务状态
349+
350+
public FingerprintCheckResult(boolean existed, String jobId, String fingerprint,
351+
String hlsPath, String status) {
352+
this.existed = existed;
353+
this.jobId = jobId;
354+
this.fingerprint = fingerprint;
355+
this.hlsPath = hlsPath;
356+
this.status = status;
357+
}
358+
}
359+
}

src/main/java/cn/programcx/foxnaserver/common/Result.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,32 @@ public static <D> Result<D> ok(D data, int code, String message) {
6666
return result;
6767
}
6868

69+
public static <D> Result<D> success(D data) {
70+
Result<D> result = new Result<>();
71+
result.success = true;
72+
result.data = data;
73+
result.code = 200;
74+
result.message = "success";
75+
return result;
76+
}
77+
78+
public static <D> Result<D> success(D data, String message) {
79+
Result<D> result = new Result<>();
80+
result.success = true;
81+
result.data = data;
82+
result.code = 200;
83+
result.message = message;
84+
return result;
85+
}
86+
87+
public static <D> Result<D> error(int code, String message) {
88+
Result<D> result = new Result<>();
89+
result.success = false;
90+
result.code = code;
91+
result.message = message;
92+
return result;
93+
}
94+
6995

7096
public static <D> Result<D> notFound(String message) {
7197
Result<D> result = new Result<>();

0 commit comments

Comments
 (0)