diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java index 49afd4f..e03d624 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java @@ -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) { diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java index d291ba3..5c3a864 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java @@ -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); } diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java index f65590f..98b45a8 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java @@ -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++) { diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformWhisperService.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformWhisperService.java new file mode 100644 index 0000000..29813ef --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformWhisperService.java @@ -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 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 body = new LinkedMultiValueMap<>(); + body.add("file", new FileSystemResource(audio)); + + ResponseEntity 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 buildDownloadCommand(String binary, String videoId, Path output) { + if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) { + throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId); + } + List 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 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) { + // 프로세스 종료 중 스트림이 닫힐 수 있다. + } + } +} diff --git a/src/main/resources/templates/shortform.html b/src/main/resources/templates/shortform.html index 9c23483..7c2bee4 100644 --- a/src/main/resources/templates/shortform.html +++ b/src/main/resources/templates/shortform.html @@ -77,6 +77,7 @@