feat: 정치 큐 등록 시 유튜브 자막 수집

This commit is contained in:
hehihoho3@gmail.com 2026-08-12 16:24:03 +09:00
parent 17194d66cc
commit d21fd4a919
10 changed files with 285 additions and 4 deletions

View File

@ -3,10 +3,12 @@ package com.hlab.yanalyst;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableJpaAuditing @EnableJpaAuditing
@EnableAsync
@EnableScheduling @EnableScheduling
public class HLabApplication { public class HLabApplication {

View File

@ -50,6 +50,12 @@ public class ShortformController {
return ApiResponse.ok(shortformService.reset(id)); return ApiResponse.ok(shortformService.reset(id));
} }
@PostMapping("/jobs/{id}/subtitles/retry")
public ApiResponse<Long> retrySubtitles(@PathVariable Long id) {
shortformService.retrySubtitle(id);
return ApiResponse.ok(id);
}
@DeleteMapping("/jobs/{id}") @DeleteMapping("/jobs/{id}")
public ApiResponse<Long> delete(@PathVariable Long id) { public ApiResponse<Long> delete(@PathVariable Long id) {
shortformService.delete(id); shortformService.delete(id);

View File

@ -57,6 +57,16 @@ public class ShortformJob {
private LocalDateTime completedAt; 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) @OneToMany(mappedBy = "job", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("clipNo ASC") @OrderBy("clipNo ASC")
private List<ShortformClip> clips = new ArrayList<>(); private List<ShortformClip> clips = new ArrayList<>();
@ -81,6 +91,32 @@ public class ShortformJob {
return ShortformCategory.normalize(this.category); 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<ParsedClip> parsed) { public void applyResult(String raw, List<ParsedClip> parsed) {
this.rawOutput = raw; this.rawOutput = raw;
this.clips.clear(); this.clips.clear();

View File

@ -5,6 +5,8 @@ import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.List; import java.util.List;
@ -14,6 +16,7 @@ public class ShortformService {
private final ShortformJobRepository jobRepository; private final ShortformJobRepository jobRepository;
private final ShortformOutputParser parser; private final ShortformOutputParser parser;
private final ShortformSubtitleService subtitleService;
/** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */ /** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
@Transactional @Transactional
@ -28,9 +31,33 @@ public class ShortformService {
ShortformJob job = jobRepository.findByVideoId(videoId) ShortformJob job = jobRepository.findByVideoId(videoId)
.orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId, category))); .orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId, category)));
job.changeCategory(category); job.changeCategory(category);
if (job.categoryOrDefault() == ShortformCategory.POLITICS
&& job.subtitleStatusOrDefault() != SubtitleStatus.READY
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
scheduleSubtitleAfterCommit(job.getId());
}
return JobDetail.from(job); 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로 남긴다. */ /** Opal 출력 원문을 파싱해 저장. 클립이 하나도 안 나오면 FAILED로 남긴다. */
@Transactional @Transactional
public JobDetail saveResult(Long jobId, String rawText, String step1Text) { public JobDetail saveResult(Long jobId, String rawText, String step1Text) {

View File

@ -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<String> 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<Path> 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<String> buildCommand(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("--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) {
// 프로세스 종료 스트림이 닫히는 것은 정상이다.
}
}
}

View File

@ -0,0 +1,9 @@
package com.hlab.yanalyst.domain.shortform;
public enum SubtitleStatus {
NOT_REQUESTED,
COLLECTING,
READY,
UNAVAILABLE,
FAILED
}

View File

@ -18,11 +18,12 @@ public final class ShortformDtos {
public record ImportRequest(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {} public record ImportRequest(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {}
public record JobSummary(Long id, String youtubeUrl, String videoId, String title, String 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) { LocalDateTime createdAt, LocalDateTime completedAt) {
public static JobSummary from(ShortformJob job) { public static JobSummary from(ShortformJob job) {
return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(), 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()); 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, public record JobDetail(Long id, String youtubeUrl, String videoId, String title, String category,
String status, LocalDateTime createdAt, LocalDateTime completedAt, String status, LocalDateTime createdAt, LocalDateTime completedAt,
String rawOutput, String step1Output, List<ClipDto> clips) { String rawOutput, String step1Output, String subtitleStatus,
String subtitleVtt, String subtitleError, List<ClipDto> clips) {
public static JobDetail from(ShortformJob job) { public static JobDetail from(ShortformJob job) {
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(), return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getCreatedAt(), job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getCreatedAt(),
job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(), job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
job.subtitleStatusOrDefault().name(), job.getSubtitleVtt(), job.getSubtitleError(),
job.getClips().stream().map(ClipDto::from).toList()); job.getClips().stream().map(ClipDto::from).toList());
} }
} }

View File

@ -62,7 +62,7 @@ ytdlp:
max-height: ${YTDLP_MAX_HEIGHT:1080} # bv*[height<=N]+ba/b, mp4 머지 max-height: ${YTDLP_MAX_HEIGHT:1080} # bv*[height<=N]+ba/b, mp4 머지
timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600} timeout-seconds: ${YTDLP_TIMEOUT_SECONDS:600}
download: download:
dir: ${DOWNLOAD_DIR:downloads} # 다운로드 캐시 폴더(작업 디렉토리 기준 상대경로 가능) dir: ${DOWNLOAD_DIR:downloads} # 원본 캐시 + 정치 큐 자막(subtitles/) 저장 폴더
# 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}. # 자가호스팅 LibreTranslate(원본 스크립트 번역 → 재가공 초안). POST /translate {q,source,target}.
translate: translate:

View File

@ -26,6 +26,9 @@
.sf-badge.PENDING { background: rgba(196, 122, 18, 0.15); color: var(--warning); } .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.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-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-clips { display: none; padding: 0.75rem 0.9rem; border-top: 1px solid var(--border); }
.sf-job.open .sf-clips { display: block; } .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; } .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 후보구간 복사</button> ${detail.step1Output ? '' : 'disabled'}>Step 1 후보구간 복사</button>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}" <button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
data-label="영상 URL 복사">영상 URL 복사</button> data-label="영상 URL 복사">영상 URL 복사</button>
${detail.category === 'POLITICS' ? `
<button class="btn btn-secondary" data-subtitle-copy
data-label="타임스탬프 자막 복사" ${detail.subtitleVtt ? '' : 'disabled'}>타임스탬프 자막 복사</button>
${detail.subtitleStatus === 'READY' || detail.subtitleStatus === 'COLLECTING' ? '' : `
<button class="btn btn-secondary" onclick="retrySubtitles(${detail.id}, event)">자막 다시 수집</button>`}` : ''}
</div>`; </div>`;
const step1Block = detail.step1Output const step1Block = detail.step1Output
? `<details class="sf-clip" style="margin-bottom:0.6rem;"> ? `<details class="sf-clip" style="margin-bottom:0.6rem;">
@ -174,6 +182,15 @@
setTimeout(() => btn.textContent = label, 1200); 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) { function esc(s) {
@ -194,6 +211,14 @@
</div>`; </div>`;
} }
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() { async function loadJobs() {
const jobs = await api('/api/shortform/jobs?category=' + currentCategory); const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
const list = document.getElementById('jobList'); const list = document.getElementById('jobList');
@ -206,6 +231,8 @@
<div class="sf-job-title"> <div class="sf-job-title">
<div class="t">${esc(j.title || j.videoId)}</div> <div class="t">${esc(j.title || j.videoId)}</div>
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div> <div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
${j.category === 'POLITICS' ? `<div class="sf-subtitle ${j.subtitleStatus}"
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j.subtitleStatus)}</div>` : ''}
</div> </div>
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span> <span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
<span class="sf-badge ${j.status}">${j.status}</span> <span class="sf-badge ${j.status}">${j.status}</span>
@ -224,6 +251,11 @@
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
} }
function subtitleLabel(status) {
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',
UNAVAILABLE:'없음', FAILED:'실패'})[status] || '대기';
}
switchCategory(currentCategory); switchCategory(currentCategory);
</script> </script>
</th:block> </th:block>

View File

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