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 b5d0c7b..d291ba3 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java @@ -31,8 +31,7 @@ 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 + if (job.subtitleStatusOrDefault() != SubtitleStatus.READY && job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) { scheduleSubtitleAfterCommit(job.getId()); } @@ -52,9 +51,6 @@ public class ShortformService { public void retrySubtitle(Long jobId) { ShortformJob job = jobRepository.findById(jobId) .orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId)); - if (job.categoryOrDefault() != ShortformCategory.POLITICS) { - throw new IllegalArgumentException("정치 큐 작업만 자막을 수집합니다"); - } scheduleSubtitleAfterCommit(jobId); } diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java index a304175..f65590f 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleService.java @@ -18,13 +18,14 @@ import java.util.List; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; -/** 정치 숏폼 큐 영상의 YouTube 자막만 yt-dlp로 수집한다. 영상 파일은 내려받지 않는다. */ +/** 숏폼 큐 영상의 YouTube 자막만 yt-dlp로 수집한다. 영상 파일은 내려받지 않는다. */ @Slf4j @Service @RequiredArgsConstructor public class ShortformSubtitleService { 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; @@ -40,22 +41,21 @@ public class ShortformSubtitleService { @Async public void collectAsync(Long jobId) { ShortformJob job = jobRepository.findById(jobId).orElse(null); - if (job == null || job.categoryOrDefault() != ShortformCategory.POLITICS - || job.subtitleStatusOrDefault() == SubtitleStatus.READY) { + if (job == null || job.subtitleStatusOrDefault() == SubtitleStatus.READY) { return; } job.markSubtitleCollecting(); jobRepository.save(job); try { - String vtt = collect(job.getVideoId()); + String vtt = collectWithRetry(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); + log.warn("숏폼 큐 자막 수집 실패: jobId={}, videoId={}", jobId, job.getVideoId(), e); jobRepository.findById(jobId).ifPresent(current -> { current.markSubtitleFailed(e.getMessage()); 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 { if (videoId == null || !VIDEO_ID.matcher(videoId).matches()) { throw new IllegalArgumentException("잘못된 videoId 형식입니다: " + videoId); diff --git a/src/main/resources/templates/shortform.html b/src/main/resources/templates/shortform.html index 5db150a..9c23483 100644 --- a/src/main/resources/templates/shortform.html +++ b/src/main/resources/templates/shortform.html @@ -157,11 +157,10 @@ ${detail.step1Output ? '' : 'disabled'}>Step 1 후보구간 복사 - ${detail.category === 'POLITICS' ? ` ${detail.subtitleStatus === 'READY' || detail.subtitleStatus === 'COLLECTING' ? '' : ` - `}` : ''} + `} `; const step1Block = detail.step1Output ? `
@@ -231,8 +230,8 @@
${esc(j.title || j.videoId)}
${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개
- ${j.category === 'POLITICS' ? `
자막 ${subtitleLabel(j.subtitleStatus)}
` : ''} +
자막 ${subtitleLabel(j.subtitleStatus)}
${j.category === 'POLITICS' ? '정치' : '예능'} ${j.status} diff --git a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleServiceTest.java b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleServiceTest.java index 2092859..2bcfcc8 100644 --- a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleServiceTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformSubtitleServiceTest.java @@ -4,9 +4,16 @@ import org.junit.jupiter.api.Test; import java.nio.file.Path; import java.util.List; +import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; 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 { @@ -26,4 +33,30 @@ class ShortformSubtitleServiceTest { "yt-dlp", "bad;id", Path.of("out"))) .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"); + } }