feat: 숏폼 자막 Whisper 폴백 추가

This commit is contained in:
hehihoho3@gmail.com 2026-08-13 14:32:29 +09:00
parent f284a5b1e5
commit 9a3047142d
7 changed files with 289 additions and 9 deletions

View File

@ -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) {

View File

@ -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);
}

View File

@ -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++) {

View File

@ -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) {
// 프로세스 종료 스트림이 닫힐 있다.
}
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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;
}
}