feat: 숏폼 자막 Whisper 폴백 추가
This commit is contained in:
parent
f284a5b1e5
commit
9a3047142d
@ -96,8 +96,12 @@ public class ShortformJob {
|
||||
}
|
||||
|
||||
public void markSubtitleCollecting() {
|
||||
markSubtitleCollecting(null);
|
||||
}
|
||||
|
||||
public void markSubtitleCollecting(String message) {
|
||||
subtitleStatus = SubtitleStatus.COLLECTING;
|
||||
subtitleError = null;
|
||||
subtitleError = message;
|
||||
}
|
||||
|
||||
public void storeSubtitle(String vtt) {
|
||||
|
||||
@ -33,6 +33,7 @@ public class ShortformService {
|
||||
job.changeCategory(category);
|
||||
if (job.subtitleStatusOrDefault() != SubtitleStatus.READY
|
||||
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
|
||||
job.markSubtitleCollecting("자막 수집 대기 중");
|
||||
scheduleSubtitleAfterCommit(job.getId());
|
||||
}
|
||||
return JobDetail.from(job);
|
||||
@ -48,9 +49,11 @@ public class ShortformService {
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void retrySubtitle(Long jobId) {
|
||||
ShortformJob job = jobRepository.findById(jobId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
||||
job.markSubtitleCollecting("자막 재수집 대기 중");
|
||||
scheduleSubtitleAfterCommit(jobId);
|
||||
}
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ public class ShortformSubtitleService {
|
||||
private static final int MAX_COLLECTION_ATTEMPTS = 2;
|
||||
|
||||
private final ShortformJobRepository jobRepository;
|
||||
private final ShortformWhisperService whisperService;
|
||||
|
||||
@Value("${ytdlp.bin:yt-dlp}")
|
||||
private String ytdlpBin;
|
||||
@ -44,11 +45,11 @@ public class ShortformSubtitleService {
|
||||
if (job == null || job.subtitleStatusOrDefault() == SubtitleStatus.READY) {
|
||||
return;
|
||||
}
|
||||
job.markSubtitleCollecting();
|
||||
job.markSubtitleCollecting("YouTube 자막 확인 중");
|
||||
jobRepository.save(job);
|
||||
|
||||
try {
|
||||
String vtt = collectWithRetry(job.getVideoId());
|
||||
String vtt = collectOrTranscribe(jobId, job.getVideoId());
|
||||
ShortformJob current = jobRepository.findById(jobId).orElse(null);
|
||||
if (current == null) return;
|
||||
if (vtt == null) current.markSubtitleUnavailable("한국어 공식/자동 자막이 없습니다");
|
||||
@ -63,6 +64,24 @@ public class ShortformSubtitleService {
|
||||
}
|
||||
}
|
||||
|
||||
String collectOrTranscribe(Long jobId, String videoId) throws IOException, InterruptedException {
|
||||
try {
|
||||
String vtt = collectWithRetry(videoId);
|
||||
if (vtt != null) return vtt;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.warn("YouTube VTT 수집 실패, Whisper 폴백 실행: videoId={}", videoId, e);
|
||||
}
|
||||
updateProgress(jobId, "YouTube 자막 없음 · Whisper 자막 생성 중");
|
||||
return whisperService.transcribe(videoId);
|
||||
}
|
||||
|
||||
private void updateProgress(Long jobId, String message) {
|
||||
jobRepository.findById(jobId).ifPresent(current -> {
|
||||
current.markSubtitleCollecting(message);
|
||||
jobRepository.save(current);
|
||||
});
|
||||
}
|
||||
|
||||
String collectWithRetry(String videoId) throws IOException, InterruptedException {
|
||||
Exception lastFailure = null;
|
||||
for (int attempt = 1; attempt <= MAX_COLLECTION_ATTEMPTS; attempt++) {
|
||||
|
||||
@ -0,0 +1,187 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** YouTube 자막이 없는 숏폼 영상을 Whisper로 전사해 VTT를 생성한다. */
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ShortformWhisperService {
|
||||
|
||||
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
|
||||
|
||||
@Qualifier("pythonRestTemplate")
|
||||
private final RestTemplate pythonRestTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ShortformWhisperService(@Qualifier("pythonRestTemplate") RestTemplate pythonRestTemplate,
|
||||
ObjectMapper objectMapper) {
|
||||
this.pythonRestTemplate = pythonRestTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Value("${python.base-url:http://h-python.tolag.shop}")
|
||||
private String pythonBaseUrl;
|
||||
|
||||
@Value("${ytdlp.bin:yt-dlp}")
|
||||
private String ytdlpBin;
|
||||
|
||||
@Value("${ytdlp.timeout-seconds:600}")
|
||||
private long timeoutSeconds;
|
||||
|
||||
@Value("${download.dir:downloads}")
|
||||
private String downloadDir;
|
||||
|
||||
public String transcribe(String videoId) throws IOException, InterruptedException {
|
||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||
}
|
||||
|
||||
Path dir = Path.of(downloadDir, "subtitles", videoId, "whisper");
|
||||
Files.createDirectories(dir);
|
||||
try {
|
||||
Path audio = downloadAudio(videoId, dir);
|
||||
ScriptResponseDto response = requestTranscription(audio);
|
||||
return toVtt(response == null ? null : response.getSegments());
|
||||
} finally {
|
||||
deleteFiles(dir);
|
||||
}
|
||||
}
|
||||
|
||||
private Path downloadAudio(String videoId, Path dir) throws IOException, InterruptedException {
|
||||
List<String> command = buildDownloadCommand(ytdlpBin, videoId, dir.resolve("source.%(ext)s"));
|
||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
StringBuilder output = new StringBuilder();
|
||||
Thread drainer = new Thread(() -> drain(process, output), "yt-dlp-audio-" + videoId);
|
||||
drainer.setDaemon(true);
|
||||
drainer.start();
|
||||
|
||||
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
throw new RuntimeException("yt-dlp 오디오 다운로드 타임아웃(" + timeoutSeconds + "s)");
|
||||
}
|
||||
drainer.join(2000);
|
||||
if (process.exitValue() != 0) {
|
||||
String logText = output.toString();
|
||||
throw new RuntimeException("yt-dlp 오디오 다운로드 실패: "
|
||||
+ logText.substring(Math.max(0, logText.length() - 500)).strip());
|
||||
}
|
||||
|
||||
try (var files = Files.list(dir)) {
|
||||
return files.filter(Files::isRegularFile)
|
||||
.max(Comparator.comparingLong(ShortformWhisperService::fileSize))
|
||||
.orElseThrow(() -> new RuntimeException("다운로드된 오디오 파일이 없습니다"));
|
||||
}
|
||||
}
|
||||
|
||||
private ScriptResponseDto requestTranscription(Path audio) throws IOException {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", new FileSystemResource(audio));
|
||||
|
||||
ResponseEntity<String> response = pythonRestTemplate.postForEntity(
|
||||
pythonBaseUrl + "/transcribe", new HttpEntity<>(body, headers), String.class);
|
||||
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw new RuntimeException("Whisper API 실패: " + response.getStatusCode());
|
||||
}
|
||||
return objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
||||
}
|
||||
|
||||
static List<String> buildDownloadCommand(String binary, String videoId, Path output) {
|
||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||
}
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(binary);
|
||||
command.add("--no-playlist");
|
||||
command.add("--force-overwrites");
|
||||
command.add("-f");
|
||||
command.add("bestaudio/best");
|
||||
command.add("-o");
|
||||
command.add(output.toString());
|
||||
command.add("https://www.youtube.com/watch?v=" + videoId);
|
||||
return command;
|
||||
}
|
||||
|
||||
static String toVtt(List<ScriptResponseDto.Segment> segments) {
|
||||
if (segments == null || segments.isEmpty()) return null;
|
||||
StringBuilder vtt = new StringBuilder("WEBVTT\n\n");
|
||||
int cue = 1;
|
||||
for (ScriptResponseDto.Segment segment : segments) {
|
||||
if (segment == null || segment.getText() == null || segment.getText().isBlank()
|
||||
|| segment.getEnd() <= segment.getStart()) continue;
|
||||
vtt.append(cue++).append('\n')
|
||||
.append(formatTimestamp(segment.getStart())).append(" --> ")
|
||||
.append(formatTimestamp(segment.getEnd())).append('\n')
|
||||
.append(segment.getText().strip()).append("\n\n");
|
||||
}
|
||||
return cue == 1 ? null : vtt.toString();
|
||||
}
|
||||
|
||||
static String formatTimestamp(double seconds) {
|
||||
long millis = Math.max(0, Math.round(seconds * 1000));
|
||||
long hours = millis / 3_600_000;
|
||||
long minutes = (millis % 3_600_000) / 60_000;
|
||||
long secs = (millis % 60_000) / 1000;
|
||||
long ms = millis % 1000;
|
||||
return "%02d:%02d:%02d.%03d".formatted(hours, minutes, secs, ms);
|
||||
}
|
||||
|
||||
private static long fileSize(Path path) {
|
||||
try {
|
||||
return Files.size(path);
|
||||
} catch (IOException ignored) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteFiles(Path dir) {
|
||||
try (var files = Files.list(dir)) {
|
||||
files.filter(Files::isRegularFile).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("Whisper 임시 파일 삭제 실패: {}", path, e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("Whisper 임시 폴더 정리 실패: {}", dir, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void drain(Process process, StringBuilder target) {
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) target.append(line).append('\n');
|
||||
} catch (IOException ignored) {
|
||||
// 프로세스 종료 중 스트림이 닫힐 수 있다.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -77,6 +77,7 @@
|
||||
<script>
|
||||
let currentCategory = new URLSearchParams(location.search).get('category') === 'POLITICS'
|
||||
? 'POLITICS' : 'ENTERTAINMENT';
|
||||
let subtitlePollTimer = null;
|
||||
|
||||
async function api(path, opts) {
|
||||
const res = await fetch(path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts));
|
||||
@ -231,7 +232,7 @@
|
||||
<div class="t">${esc(j.title || j.videoId)}</div>
|
||||
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
|
||||
<div class="sf-subtitle ${j.subtitleStatus}"
|
||||
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j.subtitleStatus)}</div>
|
||||
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j)}</div>
|
||||
</div>
|
||||
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
|
||||
<span class="sf-badge ${j.status}">${j.status}</span>
|
||||
@ -248,11 +249,16 @@
|
||||
list.appendChild(el);
|
||||
});
|
||||
if (window.lucide) lucide.createIcons();
|
||||
clearTimeout(subtitlePollTimer);
|
||||
if (jobs.some(j => j.subtitleStatus === 'COLLECTING')) {
|
||||
subtitlePollTimer = setTimeout(loadJobs, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function subtitleLabel(status) {
|
||||
function subtitleLabel(job) {
|
||||
if (job.subtitleStatus === 'COLLECTING' && job.subtitleError) return job.subtitleError;
|
||||
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',
|
||||
UNAVAILABLE:'없음', FAILED:'실패'})[status] || '대기';
|
||||
UNAVAILABLE:'없음', FAILED:'실패'})[job.subtitleStatus] || '대기';
|
||||
}
|
||||
|
||||
switchCategory(currentCategory);
|
||||
|
||||
@ -36,13 +36,26 @@ class ShortformSubtitleServiceTest {
|
||||
|
||||
@Test
|
||||
void retriesOnceWhenFirstCollectionHasNoSubtitle() throws Exception {
|
||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(mock(ShortformJobRepository.class)));
|
||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(
|
||||
mock(ShortformJobRepository.class), mock(ShortformWhisperService.class)));
|
||||
doReturn(null, "WEBVTT\n").when(service).collect("gDfGqRDubCg");
|
||||
|
||||
assertThat(service.collectWithRetry("gDfGqRDubCg")).isEqualTo("WEBVTT\n");
|
||||
verify(service, times(2)).collect("gDfGqRDubCg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToWhisperWhenYoutubeHasNoSubtitle() throws Exception {
|
||||
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||
ShortformWhisperService whisperService = mock(ShortformWhisperService.class);
|
||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(repository, whisperService));
|
||||
doReturn(null).when(service).collectWithRetry("gDfGqRDubCg");
|
||||
when(whisperService.transcribe("gDfGqRDubCg")).thenReturn("WEBVTT\n");
|
||||
|
||||
assertThat(service.collectOrTranscribe(1L, "gDfGqRDubCg")).isEqualTo("WEBVTT\n");
|
||||
verify(whisperService).transcribe("gDfGqRDubCg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectsSubtitleForEntertainmentJob() throws Exception {
|
||||
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||
@ -51,8 +64,9 @@ class ShortformSubtitleServiceTest {
|
||||
"gDfGqRDubCg",
|
||||
ShortformCategory.ENTERTAINMENT);
|
||||
when(repository.findById(1L)).thenReturn(Optional.of(job));
|
||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(repository));
|
||||
doReturn("WEBVTT\n").when(service).collectWithRetry("gDfGqRDubCg");
|
||||
ShortformSubtitleService service = spy(new ShortformSubtitleService(
|
||||
repository, mock(ShortformWhisperService.class)));
|
||||
doReturn("WEBVTT\n").when(service).collectOrTranscribe(1L, "gDfGqRDubCg");
|
||||
|
||||
service.collectAsync(1L);
|
||||
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ShortformWhisperServiceTest {
|
||||
|
||||
@Test
|
||||
void buildsAudioOnlyDownloadCommand() {
|
||||
List<String> command = ShortformWhisperService.buildDownloadCommand(
|
||||
"yt-dlp", "gDfGqRDubCg", Path.of("downloads/source.%(ext)s"));
|
||||
|
||||
assertThat(command).contains("--no-playlist", "--force-overwrites", "-f", "bestaudio/best");
|
||||
assertThat(command).endsWith("https://www.youtube.com/watch?v=gDfGqRDubCg");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidVideoId() {
|
||||
assertThatThrownBy(() -> ShortformWhisperService.buildDownloadCommand(
|
||||
"yt-dlp", "bad;id", Path.of("out")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertsWhisperSegmentsToVtt() {
|
||||
ScriptResponseDto.Segment first = segment(1.234, 4.567, "첫 번째 자막");
|
||||
ScriptResponseDto.Segment second = segment(65.0, 67.25, "두 번째 자막");
|
||||
|
||||
assertThat(ShortformWhisperService.toVtt(List.of(first, second)))
|
||||
.isEqualTo("WEBVTT\n\n1\n00:00:01.234 --> 00:00:04.567\n첫 번째 자막\n\n"
|
||||
+ "2\n00:01:05.000 --> 00:01:07.250\n두 번째 자막\n\n");
|
||||
}
|
||||
|
||||
private static ScriptResponseDto.Segment segment(double start, double end, String text) {
|
||||
ScriptResponseDto.Segment segment = new ScriptResponseDto.Segment();
|
||||
segment.setStart(start);
|
||||
segment.setEnd(end);
|
||||
segment.setText(text);
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user