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.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 {

View File

@ -50,6 +50,12 @@ public class ShortformController {
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}")
public ApiResponse<Long> delete(@PathVariable Long id) {
shortformService.delete(id);

View File

@ -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<ShortformClip> 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<ParsedClip> parsed) {
this.rawOutput = raw;
this.clips.clear();

View File

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

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 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<ClipDto> clips) {
String rawOutput, String step1Output, String subtitleStatus,
String subtitleVtt, String subtitleError, List<ClipDto> 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());
}
}

View File

@ -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:

View File

@ -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 후보구간 복사</button>
<button class="btn btn-secondary" data-copy="${encodeURIComponent(detail.youtubeUrl || '')}"
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>`;
const step1Block = detail.step1Output
? `<details class="sf-clip" style="margin-bottom:0.6rem;">
@ -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 @@
</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() {
const jobs = await api('/api/shortform/jobs?category=' + currentCategory);
const list = document.getElementById('jobList');
@ -206,6 +231,8 @@
<div class="sf-job-title">
<div class="t">${esc(j.title || j.videoId)}</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>
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
<span class="sf-badge ${j.status}">${j.status}</span>
@ -224,6 +251,11 @@
if (window.lucide) lucide.createIcons();
}
function subtitleLabel(status) {
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',
UNAVAILABLE:'없음', FAILED:'실패'})[status] || '대기';
}
switchCategory(currentCategory);
</script>
</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);
}
}