feat: 숏폼 큐 자막 자동 수집 확대
This commit is contained in:
parent
5da8121daf
commit
f284a5b1e5
@ -31,8 +31,7 @@ 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
|
if (job.subtitleStatusOrDefault() != SubtitleStatus.READY
|
||||||
&& job.subtitleStatusOrDefault() != SubtitleStatus.READY
|
|
||||||
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
|
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
|
||||||
scheduleSubtitleAfterCommit(job.getId());
|
scheduleSubtitleAfterCommit(job.getId());
|
||||||
}
|
}
|
||||||
@ -52,9 +51,6 @@ public class ShortformService {
|
|||||||
public void retrySubtitle(Long jobId) {
|
public void retrySubtitle(Long jobId) {
|
||||||
ShortformJob job = jobRepository.findById(jobId)
|
ShortformJob job = jobRepository.findById(jobId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
.orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId));
|
||||||
if (job.categoryOrDefault() != ShortformCategory.POLITICS) {
|
|
||||||
throw new IllegalArgumentException("정치 큐 작업만 자막을 수집합니다");
|
|
||||||
}
|
|
||||||
scheduleSubtitleAfterCommit(jobId);
|
scheduleSubtitleAfterCommit(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,13 +18,14 @@ import java.util.List;
|
|||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/** 정치 숏폼 큐 영상의 YouTube 자막만 yt-dlp로 수집한다. 영상 파일은 내려받지 않는다. */
|
/** 숏폼 큐 영상의 YouTube 자막만 yt-dlp로 수집한다. 영상 파일은 내려받지 않는다. */
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ShortformSubtitleService {
|
public class ShortformSubtitleService {
|
||||||
|
|
||||||
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
|
private static final Pattern VIDEO_ID = Pattern.compile("^[A-Za-z0-9_-]{11}$");
|
||||||
|
private static final int MAX_COLLECTION_ATTEMPTS = 2;
|
||||||
|
|
||||||
private final ShortformJobRepository jobRepository;
|
private final ShortformJobRepository jobRepository;
|
||||||
|
|
||||||
@ -40,22 +41,21 @@ public class ShortformSubtitleService {
|
|||||||
@Async
|
@Async
|
||||||
public void collectAsync(Long jobId) {
|
public void collectAsync(Long jobId) {
|
||||||
ShortformJob job = jobRepository.findById(jobId).orElse(null);
|
ShortformJob job = jobRepository.findById(jobId).orElse(null);
|
||||||
if (job == null || job.categoryOrDefault() != ShortformCategory.POLITICS
|
if (job == null || job.subtitleStatusOrDefault() == SubtitleStatus.READY) {
|
||||||
|| job.subtitleStatusOrDefault() == SubtitleStatus.READY) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job.markSubtitleCollecting();
|
job.markSubtitleCollecting();
|
||||||
jobRepository.save(job);
|
jobRepository.save(job);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String vtt = collect(job.getVideoId());
|
String vtt = collectWithRetry(job.getVideoId());
|
||||||
ShortformJob current = jobRepository.findById(jobId).orElse(null);
|
ShortformJob current = jobRepository.findById(jobId).orElse(null);
|
||||||
if (current == null) return;
|
if (current == null) return;
|
||||||
if (vtt == null) current.markSubtitleUnavailable("한국어 공식/자동 자막이 없습니다");
|
if (vtt == null) current.markSubtitleUnavailable("한국어 공식/자동 자막이 없습니다");
|
||||||
else current.storeSubtitle(vtt);
|
else current.storeSubtitle(vtt);
|
||||||
jobRepository.save(current);
|
jobRepository.save(current);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("정치 큐 자막 수집 실패: jobId={}, videoId={}", jobId, job.getVideoId(), e);
|
log.warn("숏폼 큐 자막 수집 실패: jobId={}, videoId={}", jobId, job.getVideoId(), e);
|
||||||
jobRepository.findById(jobId).ifPresent(current -> {
|
jobRepository.findById(jobId).ifPresent(current -> {
|
||||||
current.markSubtitleFailed(e.getMessage());
|
current.markSubtitleFailed(e.getMessage());
|
||||||
jobRepository.save(current);
|
jobRepository.save(current);
|
||||||
@ -63,6 +63,25 @@ public class ShortformSubtitleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String collectWithRetry(String videoId) throws IOException, InterruptedException {
|
||||||
|
Exception lastFailure = null;
|
||||||
|
for (int attempt = 1; attempt <= MAX_COLLECTION_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
String vtt = collect(videoId);
|
||||||
|
if (vtt != null) return vtt;
|
||||||
|
log.info("숏폼 큐 자막 없음: videoId={}, attempt={}/{}",
|
||||||
|
videoId, attempt, MAX_COLLECTION_ATTEMPTS);
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
lastFailure = e;
|
||||||
|
log.warn("숏폼 큐 자막 수집 재시도: videoId={}, attempt={}/{}",
|
||||||
|
videoId, attempt, MAX_COLLECTION_ATTEMPTS, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastFailure instanceof IOException ioException) throw ioException;
|
||||||
|
if (lastFailure instanceof RuntimeException runtimeException) throw runtimeException;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
String collect(String videoId) throws IOException, InterruptedException {
|
String collect(String videoId) throws IOException, InterruptedException {
|
||||||
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) {
|
||||||
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId);
|
||||||
|
|||||||
@ -157,11 +157,10 @@
|
|||||||
${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
|
<button class="btn btn-secondary" data-subtitle-copy
|
||||||
data-label="타임스탬프 자막 복사" ${detail.subtitleVtt ? '' : 'disabled'}>타임스탬프 자막 복사</button>
|
data-label="타임스탬프 자막 복사" ${detail.subtitleVtt ? '' : 'disabled'}>타임스탬프 자막 복사</button>
|
||||||
${detail.subtitleStatus === 'READY' || detail.subtitleStatus === 'COLLECTING' ? '' : `
|
${detail.subtitleStatus === 'READY' || detail.subtitleStatus === 'COLLECTING' ? '' : `
|
||||||
<button class="btn btn-secondary" onclick="retrySubtitles(${detail.id}, event)">자막 다시 수집</button>`}` : ''}
|
<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;">
|
||||||
@ -231,8 +230,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}"
|
<div class="sf-subtitle ${j.subtitleStatus}"
|
||||||
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j.subtitleStatus)}</div>` : ''}
|
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>
|
||||||
|
|||||||
@ -4,9 +4,16 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class ShortformSubtitleServiceTest {
|
class ShortformSubtitleServiceTest {
|
||||||
|
|
||||||
@ -26,4 +33,30 @@ class ShortformSubtitleServiceTest {
|
|||||||
"yt-dlp", "bad;id", Path.of("out")))
|
"yt-dlp", "bad;id", Path.of("out")))
|
||||||
.isInstanceOf(IllegalArgumentException.class);
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retriesOnceWhenFirstCollectionHasNoSubtitle() throws Exception {
|
||||||
|
ShortformSubtitleService service = spy(new ShortformSubtitleService(mock(ShortformJobRepository.class)));
|
||||||
|
doReturn(null, "WEBVTT\n").when(service).collect("gDfGqRDubCg");
|
||||||
|
|
||||||
|
assertThat(service.collectWithRetry("gDfGqRDubCg")).isEqualTo("WEBVTT\n");
|
||||||
|
verify(service, times(2)).collect("gDfGqRDubCg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collectsSubtitleForEntertainmentJob() throws Exception {
|
||||||
|
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||||
|
ShortformJob job = ShortformJob.create(
|
||||||
|
"https://www.youtube.com/watch?v=gDfGqRDubCg",
|
||||||
|
"gDfGqRDubCg",
|
||||||
|
ShortformCategory.ENTERTAINMENT);
|
||||||
|
when(repository.findById(1L)).thenReturn(Optional.of(job));
|
||||||
|
ShortformSubtitleService service = spy(new ShortformSubtitleService(repository));
|
||||||
|
doReturn("WEBVTT\n").when(service).collectWithRetry("gDfGqRDubCg");
|
||||||
|
|
||||||
|
service.collectAsync(1L);
|
||||||
|
|
||||||
|
assertThat(job.getSubtitleStatus()).isEqualTo(SubtitleStatus.READY);
|
||||||
|
assertThat(job.getSubtitleVtt()).isEqualTo("WEBVTT\n");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user