From d21fd4a9194c3431a8e6f4f861709c6fc3451e0f Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Wed, 12 Aug 2026 16:24:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=95=EC=B9=98=20=ED=81=90=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20=EC=9C=A0=ED=8A=9C=EB=B8=8C=20?= =?UTF-8?q?=EC=9E=90=EB=A7=89=20=EC=88=98=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/hlab/yanalyst/HLabApplication.java | 2 + .../domain/shortform/ShortformController.java | 6 + .../domain/shortform/ShortformJob.java | 36 +++++ .../domain/shortform/ShortformService.java | 27 ++++ .../shortform/ShortformSubtitleService.java | 137 ++++++++++++++++++ .../domain/shortform/SubtitleStatus.java | 9 ++ .../domain/shortform/dto/ShortformDtos.java | 9 +- src/main/resources/application.yml | 2 +- src/main/resources/templates/shortform.html | 32 ++++ .../ShortformSubtitleServiceTest.java | 29 ++++ 10 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/shortform/SubtitleStatus.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleServiceTest.java diff --git a/src/main/java/com/hlab/yanalyst/HLabApplication.java b/src/main/java/com/hlab/yanalyst/HLabApplication.java index 2eebf48..11045f5 100644 --- a/src/main/java/com/hlab/yanalyst/HLabApplication.java +++ b/src/main/java/com/hlab/yanalyst/HLabApplication.java @@ -3,10 +3,12 @@ package com.hlab.yanalyst; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableAsync @EnableScheduling public class HLabApplication { diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java index 29f60dd..ccd306f 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java @@ -50,6 +50,12 @@ public class ShortformController { return ApiResponse.ok(shortformService.reset(id)); } + @PostMapping("/jobs/{id}/subtitles/retry") + public ApiResponse retrySubtitles(@PathVariable Long id) { + shortformService.retrySubtitle(id); + return ApiResponse.ok(id); + } + @DeleteMapping("/jobs/{id}") public ApiResponse delete(@PathVariable Long id) { shortformService.delete(id); 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 28bd062..49afd4f 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java @@ -57,6 +57,16 @@ public class ShortformJob { private LocalDateTime completedAt; + @Enumerated(EnumType.STRING) + @Column(name = "subtitle_status", length = 20) + private SubtitleStatus subtitleStatus = SubtitleStatus.NOT_REQUESTED; + + @Column(name = "subtitle_vtt", columnDefinition = "TEXT") + private String subtitleVtt; + + @Column(name = "subtitle_error", length = 500) + private String subtitleError; + @OneToMany(mappedBy = "job", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("clipNo ASC") private List clips = new ArrayList<>(); @@ -81,6 +91,32 @@ public class ShortformJob { return ShortformCategory.normalize(this.category); } + public SubtitleStatus subtitleStatusOrDefault() { + return subtitleStatus == null ? SubtitleStatus.NOT_REQUESTED : subtitleStatus; + } + + public void markSubtitleCollecting() { + subtitleStatus = SubtitleStatus.COLLECTING; + subtitleError = null; + } + + public void storeSubtitle(String vtt) { + subtitleVtt = vtt; + subtitleStatus = SubtitleStatus.READY; + subtitleError = null; + } + + public void markSubtitleUnavailable(String message) { + subtitleStatus = SubtitleStatus.UNAVAILABLE; + subtitleError = message; + } + + public void markSubtitleFailed(String message) { + subtitleStatus = SubtitleStatus.FAILED; + String detail = message == null ? "자막 수집 실패" : message; + subtitleError = detail.substring(0, Math.min(detail.length(), 500)); + } + public void applyResult(String raw, List parsed) { this.rawOutput = raw; this.clips.clear(); 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 d9f19b9..b5d0c7b 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java @@ -5,6 +5,8 @@ import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.List; @@ -14,6 +16,7 @@ public class ShortformService { private final ShortformJobRepository jobRepository; private final ShortformOutputParser parser; + private final ShortformSubtitleService subtitleService; /** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */ @Transactional @@ -28,9 +31,33 @@ public class ShortformService { ShortformJob job = jobRepository.findByVideoId(videoId) .orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId, category))); job.changeCategory(category); + if (job.categoryOrDefault() == ShortformCategory.POLITICS + && job.subtitleStatusOrDefault() != SubtitleStatus.READY + && job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) { + scheduleSubtitleAfterCommit(job.getId()); + } return JobDetail.from(job); } + private void scheduleSubtitleAfterCommit(Long jobId) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { subtitleService.collectAsync(jobId); } + }); + } else { + subtitleService.collectAsync(jobId); + } + } + + public void retrySubtitle(Long jobId) { + ShortformJob job = jobRepository.findById(jobId) + .orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId)); + if (job.categoryOrDefault() != ShortformCategory.POLITICS) { + throw new IllegalArgumentException("정치 큐 작업만 자막을 수집합니다"); + } + scheduleSubtitleAfterCommit(jobId); + } + /** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */ @Transactional public JobDetail saveResult(Long jobId, String rawText, String step1Text) { diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java new file mode 100644 index 0000000..a304175 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java @@ -0,0 +1,137 @@ +package com.hlab.yanalyst.domain.shortform; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +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 자막만 yt-dlp로 수집한다. 영상 파일은 내려받지 않는다. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ShortformSubtitleService { + + private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$"); + + private final ShortformJobRepository jobRepository; + + @Value("${ytdlp.bin:yt-dlp}") + private String ytdlpBin; + + @Value("${ytdlp.timeout-seconds:600}") + private long timeoutSeconds; + + @Value("${download.dir:downloads}") + private String downloadDir; + + @Async + public void collectAsync(Long jobId) { + ShortformJob job = jobRepository.findById(jobId).orElse(null); + if (job == null || job.categoryOrDefault() != ShortformCategory.POLITICS + || job.subtitleStatusOrDefault() == SubtitleStatus.READY) { + return; + } + job.markSubtitleCollecting(); + jobRepository.save(job); + + try { + String vtt = collect(job.getVideoId()); + ShortformJob current = jobRepository.findById(jobId).orElse(null); + if (current == null) return; + if (vtt == null) current.markSubtitleUnavailable("한국어 공식/자동 자막이 없습니다"); + else current.storeSubtitle(vtt); + jobRepository.save(current); + } catch (Exception e) { + log.warn("정치 큐 자막 수집 실패: jobId={}, videoId={}", jobId, job.getVideoId(), e); + jobRepository.findById(jobId).ifPresent(current -> { + current.markSubtitleFailed(e.getMessage()); + jobRepository.save(current); + }); + } + } + + String collect(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); + Files.createDirectories(dir); + Path output = dir.resolve("%(id)s.%(ext)s"); + List command = buildCommand(ytdlpBin, videoId, output); + + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + StringBuilder outputLog = new StringBuilder(); + Thread drainer = new Thread(() -> drain(process, outputLog), "yt-dlp-subs-" + 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 = outputLog.toString(); + throw new RuntimeException("yt-dlp 자막 수집 실패: " + + logText.substring(Math.max(0, logText.length() - 500)).strip()); + } + + List files; + try (var stream = Files.list(dir)) { + files = stream.filter(p -> p.getFileName().toString().endsWith(".vtt")) + .sorted(Comparator.comparingInt(ShortformSubtitleService::subtitlePreference)) + .toList(); + } + if (files.isEmpty()) return null; + return Files.readString(files.get(0), StandardCharsets.UTF_8); + } + + static List buildCommand(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("--skip-download"); + command.add("--write-subs"); + command.add("--write-auto-subs"); + command.add("--sub-langs"); + command.add("ko,ko-orig"); + command.add("--sub-format"); + command.add("vtt"); + command.add("--output"); + command.add(output.toString()); + command.add("https://www.youtube.com/watch?v=" + videoId); + return command; + } + + private static int subtitlePreference(Path path) { + String name = path.getFileName().toString(); + if (name.endsWith(".ko.vtt")) return 0; + if (name.endsWith(".ko-orig.vtt")) return 1; + return 2; + } + + 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/java/com/hlab/yanalyst/domain/shortform/SubtitleStatus.java b/src/main/java/com/hlab/yanalyst/domain/shortform/SubtitleStatus.java new file mode 100644 index 0000000..786e94e --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/SubtitleStatus.java @@ -0,0 +1,9 @@ +package com.hlab.yanalyst.domain.shortform; + +public enum SubtitleStatus { + NOT_REQUESTED, + COLLECTING, + READY, + UNAVAILABLE, + FAILED +} diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java b/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java index 0877daf..3f52d3b 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java @@ -18,11 +18,12 @@ public final class ShortformDtos { public record ImportRequest(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {} public record JobSummary(Long id, String youtubeUrl, String videoId, String title, String category, - String status, int clipCount, + String status, String subtitleStatus, String subtitleError, int clipCount, LocalDateTime createdAt, LocalDateTime completedAt) { public static JobSummary from(ShortformJob job) { return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(), - job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getClips().size(), + job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), + job.subtitleStatusOrDefault().name(), job.getSubtitleError(), job.getClips().size(), job.getCreatedAt(), job.getCompletedAt()); } } @@ -37,11 +38,13 @@ public final class ShortformDtos { public record JobDetail(Long id, String youtubeUrl, String videoId, String title, String category, String status, LocalDateTime createdAt, LocalDateTime completedAt, - String rawOutput, String step1Output, List clips) { + String rawOutput, String step1Output, String subtitleStatus, + String subtitleVtt, String subtitleError, List clips) { public static JobDetail from(ShortformJob job) { return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(), job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getCreatedAt(), job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(), + job.subtitleStatusOrDefault().name(), job.getSubtitleVtt(), job.getSubtitleError(), job.getClips().stream().map(ClipDto::from).toList()); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b2368b0..82cf161 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -62,7 +62,7 @@ ytdlp: max-height: ${YTDLP_MAX_HEIGHT:1080} # bv*[height<=N]+ba/b, mp4 머지 timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600} download: - dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능) + dir: ${DOWNLOAD_DIR:downloads} # 원본 캐시 + 정치 큐 자막(subtitles/) 저장 폴더 # 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}. translate: diff --git a/src/main/resources/templates/shortform.html b/src/main/resources/templates/shortform.html index c315b65..5db150a 100644 --- a/src/main/resources/templates/shortform.html +++ b/src/main/resources/templates/shortform.html @@ -26,6 +26,9 @@ .sf-badge.PENDING { background: rgba(196, 122, 18, 0.15); color: var(--warning); } .sf-badge.DONE { background: rgba(var(--success-rgb), 0.15); color: var(--success); } .sf-badge.FAILED { background: rgba(var(--danger-rgb), 0.15); color: var(--danger); } + .sf-subtitle { font-size:.7rem; color:var(--text-2); white-space:nowrap; } + .sf-subtitle.READY { color:var(--success); } + .sf-subtitle.FAILED, .sf-subtitle.UNAVAILABLE { color:var(--danger); } .sf-clips { display: none; padding: 0.75rem 0.9rem; border-top: 1px solid var(--border); } .sf-job.open .sf-clips { display: block; } .sf-clip { border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.8rem; margin-bottom: 0.6rem; } @@ -154,6 +157,11 @@ ${detail.step1Output ? '' : 'disabled'}>Step 1 후보구간 복사 + ${detail.category === 'POLITICS' ? ` + + ${detail.subtitleStatus === 'READY' || detail.subtitleStatus === 'COLLECTING' ? '' : ` + `}` : ''} `; const step1Block = detail.step1Output ? `
@@ -174,6 +182,15 @@ setTimeout(() => btn.textContent = label, 1200); }; }); + const subtitleButton = el.querySelector('[data-subtitle-copy]'); + if (subtitleButton && detail.subtitleVtt) { + subtitleButton.onclick = (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(detail.subtitleVtt); + subtitleButton.textContent = '복사됨!'; + setTimeout(() => subtitleButton.textContent = '타임스탬프 자막 복사', 1200); + }; + } } function esc(s) { @@ -194,6 +211,14 @@ `; } + async function retrySubtitles(id, e) { + e.stopPropagation(); + try { + await api('/api/shortform/jobs/' + id + '/subtitles/retry', { method: 'POST' }); + loadJobs(); + } catch (err) { alert('자막 수집 요청 실패: ' + err.message); } + } + async function loadJobs() { const jobs = await api('/api/shortform/jobs?category=' + currentCategory); const list = document.getElementById('jobList'); @@ -206,6 +231,8 @@
${esc(j.title || j.videoId)}
${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개
+ ${j.category === 'POLITICS' ? `
자막 ${subtitleLabel(j.subtitleStatus)}
` : ''}
${j.category === 'POLITICS' ? '정치' : '예능'} ${j.status} @@ -224,6 +251,11 @@ if (window.lucide) lucide.createIcons(); } + function subtitleLabel(status) { + return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료', + UNAVAILABLE:'없음', FAILED:'실패'})[status] || '대기'; + } + switchCategory(currentCategory); diff --git a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleServiceTest.java b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleServiceTest.java new file mode 100644 index 0000000..2092859 --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleServiceTest.java @@ -0,0 +1,29 @@ +package com.hlab.yanalyst.domain.shortform; + +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 ShortformSubtitleServiceTest { + + @Test + void buildsSubtitleOnlyCommandForKoreanCaptions() { + List command = ShortformSubtitleService.buildCommand( + "yt-dlp", "gDfGqRDubCg", Path.of("downloads/subtitles/%(id)s.%(ext)s")); + + assertThat(command).contains("--skip-download", "--write-subs", "--write-auto-subs", + "--sub-langs", "ko,ko-orig", "--sub-format", "vtt"); + assertThat(command).endsWith("https://www.youtube.com/watch?v=gDfGqRDubCg"); + } + + @Test + void rejectsInvalidVideoIdBeforeStartingProcess() { + assertThatThrownBy(() -> ShortformSubtitleService.buildCommand( + "yt-dlp", "bad;id", Path.of("out"))) + .isInstanceOf(IllegalArgumentException.class); + } +}