fix: 자막 상태 갱신과 재시작 복구 개선

This commit is contained in:
hehihoho3@gmail.com 2026-08-13 15:02:26 +09:00
parent 9d27ad3d79
commit 504d0f09d5
4 changed files with 84 additions and 2 deletions

View File

@ -11,6 +11,7 @@ public interface ShortformJobRepository extends JpaRepository<ShortformJob, Long
Optional<ShortformJob> findByVideoId(String videoId); Optional<ShortformJob> findByVideoId(String videoId);
List<ShortformJob> findAllByOrderByCreatedAtDesc(); List<ShortformJob> findAllByOrderByCreatedAtDesc();
List<ShortformJob> findByStatusOrderByCreatedAtAsc(ShortformJobStatus status); List<ShortformJob> findByStatusOrderByCreatedAtAsc(ShortformJobStatus status);
List<ShortformJob> findBySubtitleStatus(SubtitleStatus subtitleStatus);
@Query("select j from ShortformJob j where coalesce(j.category, " @Query("select j from ShortformJob j where coalesce(j.category, "
+ "com.hlab.yanalyst.domain.shortform.ShortformCategory.ENTERTAINMENT) = :category " + "com.hlab.yanalyst.domain.shortform.ShortformCategory.ENTERTAINMENT) = :category "
+ "order by j.createdAt desc") + "order by j.createdAt desc")

View File

@ -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.JobDetail;
import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary; import com.hlab.yanalyst.domain.shortform.dto.ShortformDtos.JobSummary;
import lombok.RequiredArgsConstructor; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
@ -12,12 +15,23 @@ import java.util.List;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class ShortformService { public class ShortformService {
private final ShortformJobRepository jobRepository; private final ShortformJobRepository jobRepository;
private final ShortformOutputParser parser; private final ShortformOutputParser parser;
private final ShortformSubtitleService subtitleService; private final ShortformSubtitleService subtitleService;
/** 배포·재시작으로 중단된 비동기 자막 수집을 애플리케이션 기동 후 다시 시작한다. */
@EventListener(ApplicationReadyEvent.class)
public void recoverInterruptedSubtitleCollections() {
List<ShortformJob> interrupted = jobRepository.findBySubtitleStatus(SubtitleStatus.COLLECTING);
if (!interrupted.isEmpty()) {
log.info("중단된 숏폼 자막 수집 {}건 재개", interrupted.size());
}
interrupted.forEach(job -> subtitleService.collectAsync(job.getId()));
}
/** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */ /** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
@Transactional @Transactional
public JobDetail register(String youtubeUrl) { public JobDetail register(String youtubeUrl) {

View File

@ -235,12 +235,13 @@
jobs.forEach(j => { jobs.forEach(j => {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'sf-job'; el.className = 'sf-job';
el.dataset.jobId = j.id;
el.innerHTML = `<div class="sf-job-head"> el.innerHTML = `<div class="sf-job-head">
<img src="https://img.youtube.com/vi/${j.videoId}/mqdefault.jpg" alt=""> <img src="https://img.youtube.com/vi/${j.videoId}/mqdefault.jpg" alt="">
<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>
<div class="sf-subtitle ${j.subtitleStatus}" <div class="sf-subtitle ${j.subtitleStatus}" data-subtitle-status="${j.subtitleStatus}"
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j)}</div> title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j)}</div>
</div> </div>
<span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span> <span class="sf-category ${j.category}">${j.category === 'POLITICS' ? '정치' : '예능'}</span>
@ -261,12 +262,51 @@
} }
}); });
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
scheduleSubtitlePoll(jobs);
}
function scheduleSubtitlePoll(jobs) {
clearTimeout(subtitlePollTimer); clearTimeout(subtitlePollTimer);
if (jobs.some(j => j.subtitleStatus === 'COLLECTING')) { 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) { function subtitleLabel(job) {
if (job.subtitleStatus === 'COLLECTING' && job.subtitleError) return job.subtitleError; if (job.subtitleStatus === 'COLLECTING' && job.subtitleError) return job.subtitleError;
return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료', return ({NOT_REQUESTED:'대기', COLLECTING:'수집 중…', READY:'준비 완료',

View File

@ -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);
}
}