diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java index 3740b78..16eb45a 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java @@ -11,6 +11,7 @@ public interface ShortformJobRepository extends JpaRepository findByVideoId(String videoId); List findAllByOrderByCreatedAtDesc(); List findByStatusOrderByCreatedAtAsc(ShortformJobStatus status); + List findBySubtitleStatus(SubtitleStatus subtitleStatus); @Query("select j from ShortformJob j where coalesce(j.category, " + "com.hlab.yanalyst.domain.shortform.ShortformCategory.ENTERTAINMENT) = :category " + "order by j.createdAt desc") 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 5c3a864..7834990 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java @@ -3,6 +3,9 @@ package com.hlab.yanalyst.domain.shortform; import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobDetail; import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -12,12 +15,23 @@ import java.util.List; @Service @RequiredArgsConstructor +@Slf4j public class ShortformService { private final ShortformJobRepository jobRepository; private final ShortformOutputParser parser; private final ShortformSubtitleService subtitleService; + /** 배포·재시작으로 중단된 비동기 자막 수집을 애플리케이션 기동 후 다시 시작한다. */ + @EventListener(ApplicationReadyEvent.class) + public void recoverInterruptedSubtitleCollections() { + List interrupted = jobRepository.findBySubtitleStatus(SubtitleStatus.COLLECTING); + if (!interrupted.isEmpty()) { + log.info("중단된 숏폼 자막 수집 {}건 재개", interrupted.size()); + } + interrupted.forEach(job -> subtitleService.collectAsync(job.getId())); + } + /** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */ @Transactional public JobDetail register(String youtubeUrl) { diff --git a/src/main/resources/templates/shortform.html b/src/main/resources/templates/shortform.html index 58acfea..9ae0c61 100644 --- a/src/main/resources/templates/shortform.html +++ b/src/main/resources/templates/shortform.html @@ -235,12 +235,13 @@ jobs.forEach(j => { const el = document.createElement('div'); el.className = 'sf-job'; + el.dataset.jobId = j.id; el.innerHTML = `
${esc(j.title || j.videoId)}
${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개
-
자막 ${subtitleLabel(j)}
${j.category === 'POLITICS' ? '정치' : '예능'} @@ -261,12 +262,51 @@ } }); if (window.lucide) lucide.createIcons(); + scheduleSubtitlePoll(jobs); + } + + function scheduleSubtitlePoll(jobs) { clearTimeout(subtitlePollTimer); if (jobs.some(j => j.subtitleStatus === 'COLLECTING')) { - subtitlePollTimer = setTimeout(loadJobs, 2000); + subtitlePollTimer = setTimeout(pollSubtitleStatuses, 2000); } } + async function pollSubtitleStatuses() { + try { + const jobs = await api('/api/shortform/jobs?category=' + currentCategory); + jobs.forEach(j => { + const card = document.querySelector(`.sf-job[data-job-id="${j.id}"]`); + const status = card && card.querySelector('[data-subtitle-status]'); + if (!status) return; + const previous = status.dataset.subtitleStatus; + status.dataset.subtitleStatus = j.subtitleStatus; + status.className = 'sf-subtitle ' + j.subtitleStatus; + status.title = j.subtitleError || ''; + status.textContent = '자막 ' + subtitleLabel(j); + if (previous === 'COLLECTING' && j.subtitleStatus === 'READY' && card.classList.contains('open')) { + refreshSubtitleCopy(j.id, card); + } + }); + scheduleSubtitlePoll(jobs); + } catch (err) { + subtitlePollTimer = setTimeout(pollSubtitleStatuses, 5000); + } + } + + async function refreshSubtitleCopy(id, card) { + const detail = await api('/api/shortform/jobs/' + id); + const button = card.querySelector('[data-subtitle-copy]'); + if (!button || !detail.subtitleVtt) return; + button.disabled = false; + button.onclick = (e) => { + e.stopPropagation(); + navigator.clipboard.writeText(detail.subtitleVtt); + button.textContent = '복사됨!'; + setTimeout(() => button.textContent = '타임스탬프 자막 복사', 1200); + }; + } + function subtitleLabel(job) { if (job.subtitleStatus === 'COLLECTING' && job.subtitleError) return job.subtitleError; return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료', diff --git a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformServiceTest.java b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformServiceTest.java new file mode 100644 index 0000000..f9488a4 --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformServiceTest.java @@ -0,0 +1,27 @@ +package com.hlab.yanalyst.domain.shortform; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ShortformServiceTest { + + @Test + void resumesCollectingSubtitleJobsAfterRestart() { + ShortformJobRepository repository = mock(ShortformJobRepository.class); + ShortformSubtitleService subtitleService = mock(ShortformSubtitleService.class); + ShortformJob job = mock(ShortformJob.class); + when(job.getId()).thenReturn(34L); + when(repository.findBySubtitleStatus(SubtitleStatus.COLLECTING)).thenReturn(List.of(job)); + ShortformService service = new ShortformService( + repository, mock(ShortformOutputParser.class), subtitleService); + + service.recoverInterruptedSubtitleCollections(); + + verify(subtitleService).collectAsync(34L); + } +}