feat: 정치 큐 등록 시 유튜브 자막 수집
This commit is contained in:
parent
17194d66cc
commit
d21fd4a919
@ -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 {
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
// 프로세스 종료 시 스트림이 닫히는 것은 정상이다.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.hlab.yanalyst.domain.shortform;
|
||||
|
||||
public enum SubtitleStatus {
|
||||
NOT_REQUESTED,
|
||||
COLLECTING,
|
||||
READY,
|
||||
UNAVAILABLE,
|
||||
FAILED
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user