fix: 자막 상태 갱신과 재시작 복구 개선
This commit is contained in:
parent
9d27ad3d79
commit
504d0f09d5
@ -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")
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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:'준비 완료',
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user