feat: 숏폼 큐 원본 영상 제목 표시
This commit is contained in:
parent
51fcfffffa
commit
0e155c852c
@ -32,6 +32,10 @@ public class ShortformJob {
|
|||||||
@Column(nullable = false, length = 20)
|
@Column(nullable = false, length = 20)
|
||||||
private String videoId;
|
private String videoId;
|
||||||
|
|
||||||
|
/** YouTube 원본 영상 제목. 기존 행은 시작 시 자동으로 채운다. */
|
||||||
|
@Column(length = 500)
|
||||||
|
private String videoTitle;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "content_category", length = 20)
|
@Column(name = "content_category", length = 20)
|
||||||
private ShortformCategory category = ShortformCategory.ENTERTAINMENT;
|
private ShortformCategory category = ShortformCategory.ENTERTAINMENT;
|
||||||
@ -87,6 +91,12 @@ public class ShortformJob {
|
|||||||
this.category = ShortformCategory.normalize(category);
|
this.category = ShortformCategory.normalize(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void updateVideoTitle(String videoTitle) {
|
||||||
|
if (videoTitle != null && !videoTitle.isBlank()) {
|
||||||
|
this.videoTitle = videoTitle.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ShortformCategory categoryOrDefault() {
|
public ShortformCategory categoryOrDefault() {
|
||||||
return ShortformCategory.normalize(this.category);
|
return ShortformCategory.normalize(this.category);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,8 @@ public interface ShortformJobRepository extends JpaRepository<ShortformJob, Long
|
|||||||
List<ShortformJob> findAllByOrderByCreatedAtDesc();
|
List<ShortformJob> findAllByOrderByCreatedAtDesc();
|
||||||
List<ShortformJob> findByStatusOrderByCreatedAtAsc(ShortformJobStatus status);
|
List<ShortformJob> findByStatusOrderByCreatedAtAsc(ShortformJobStatus status);
|
||||||
List<ShortformJob> findBySubtitleStatus(SubtitleStatus subtitleStatus);
|
List<ShortformJob> findBySubtitleStatus(SubtitleStatus subtitleStatus);
|
||||||
|
|
||||||
|
List<ShortformJob> findByVideoTitleIsNull();
|
||||||
@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")
|
||||||
|
|||||||
@ -21,6 +21,7 @@ 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;
|
||||||
|
private final ShortformVideoTitleClient titleClient;
|
||||||
|
|
||||||
/** 배포·재시작으로 중단된 비동기 자막 수집을 애플리케이션 기동 후 다시 시작한다. */
|
/** 배포·재시작으로 중단된 비동기 자막 수집을 애플리케이션 기동 후 다시 시작한다. */
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@ -32,6 +33,17 @@ public class ShortformService {
|
|||||||
interrupted.forEach(job -> subtitleService.collectAsync(job.getId()));
|
interrupted.forEach(job -> subtitleService.collectAsync(job.getId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ddl-auto로 추가된 원본 제목 컬럼을 기존 작업에도 채운다. */
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
@Transactional
|
||||||
|
public void fillMissingVideoTitles() {
|
||||||
|
List<ShortformJob> jobs = jobRepository.findByVideoTitleIsNull();
|
||||||
|
jobs.forEach(this::fillVideoTitle);
|
||||||
|
if (!jobs.isEmpty()) {
|
||||||
|
log.info("기존 숏폼 작업 원본 영상 제목 갱신 시도: {}건", jobs.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
|
/** URL 등록. 같은 videoId가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
|
||||||
@Transactional
|
@Transactional
|
||||||
public JobDetail register(String youtubeUrl) {
|
public JobDetail register(String youtubeUrl) {
|
||||||
@ -45,6 +57,9 @@ 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.getVideoTitle() == null) {
|
||||||
|
fillVideoTitle(job);
|
||||||
|
}
|
||||||
if (job.subtitleStatusOrDefault() != SubtitleStatus.READY
|
if (job.subtitleStatusOrDefault() != SubtitleStatus.READY
|
||||||
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
|
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
|
||||||
job.markSubtitleCollecting("자막 수집 대기 중");
|
job.markSubtitleCollecting("자막 수집 대기 중");
|
||||||
@ -53,6 +68,10 @@ public class ShortformService {
|
|||||||
return JobDetail.from(job);
|
return JobDetail.from(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void fillVideoTitle(ShortformJob job) {
|
||||||
|
job.updateVideoTitle(titleClient.fetchTitle(job.getYoutubeUrl()));
|
||||||
|
}
|
||||||
|
|
||||||
private void scheduleSubtitleAfterCommit(Long jobId) {
|
private void scheduleSubtitleAfterCommit(Long jobId) {
|
||||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.hlab.yanalyst.domain.shortform;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** API 키가 필요 없는 YouTube oEmbed 응답에서 원본 영상 제목을 읽는다. */
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class ShortformVideoTitleClient {
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
|
||||||
|
public ShortformVideoTitleClient(@Qualifier("restTemplate") RestTemplate restTemplate) {
|
||||||
|
this.restTemplate = restTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String fetchTitle(String youtubeUrl) {
|
||||||
|
try {
|
||||||
|
String encodedUrl = URLEncoder.encode(youtubeUrl, StandardCharsets.UTF_8);
|
||||||
|
URI uri = URI.create("https://www.youtube.com/oembed?url=" + encodedUrl + "&format=json");
|
||||||
|
OEmbedResponse response = restTemplate.getForObject(uri, OEmbedResponse.class);
|
||||||
|
return response == null ? null : response.title();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("YouTube 원본 영상 제목 조회 실패: url={}", youtubeUrl, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record OEmbedResponse(String title) {}
|
||||||
|
}
|
||||||
@ -17,11 +17,11 @@ public final class ShortformDtos {
|
|||||||
|
|
||||||
public record ImportRequest(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {}
|
public record ImportRequest(String youtubeUrl, String rawText, String step1Text, ShortformCategory category) {}
|
||||||
|
|
||||||
public record JobSummary(Long id, String youtubeUrl, String videoId, String title, String category,
|
public record JobSummary(Long id, String youtubeUrl, String videoId, String videoTitle, String title, String category,
|
||||||
String status, String subtitleStatus, String subtitleError, int clipCount,
|
String status, String subtitleStatus, String subtitleError, int clipCount,
|
||||||
LocalDateTime createdAt, LocalDateTime completedAt) {
|
LocalDateTime createdAt, LocalDateTime completedAt) {
|
||||||
public static JobSummary from(ShortformJob job) {
|
public static JobSummary from(ShortformJob job) {
|
||||||
return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
|
return new JobSummary(job.getId(), job.getYoutubeUrl(), job.getVideoId(), job.getVideoTitle(),
|
||||||
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(),
|
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(),
|
||||||
job.subtitleStatusOrDefault().name(), job.getSubtitleError(), job.getClips().size(),
|
job.subtitleStatusOrDefault().name(), job.getSubtitleError(), job.getClips().size(),
|
||||||
job.getCreatedAt(), job.getCompletedAt());
|
job.getCreatedAt(), job.getCompletedAt());
|
||||||
@ -36,12 +36,12 @@ public final class ShortformDtos {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record JobDetail(Long id, String youtubeUrl, String videoId, String title, String category,
|
public record JobDetail(Long id, String youtubeUrl, String videoId, String videoTitle, String title, String category,
|
||||||
String status, LocalDateTime createdAt, LocalDateTime completedAt,
|
String status, LocalDateTime createdAt, LocalDateTime completedAt,
|
||||||
String rawOutput, String step1Output, String subtitleStatus,
|
String rawOutput, String step1Output, String subtitleStatus,
|
||||||
String subtitleVtt, String subtitleError, List<ClipDto> clips) {
|
String subtitleVtt, String subtitleError, List<ClipDto> clips) {
|
||||||
public static JobDetail from(ShortformJob job) {
|
public static JobDetail from(ShortformJob job) {
|
||||||
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(),
|
return new JobDetail(job.getId(), job.getYoutubeUrl(), job.getVideoId(), job.getVideoTitle(),
|
||||||
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getCreatedAt(),
|
job.getTitle(), job.categoryOrDefault().name(), job.getStatus().name(), job.getCreatedAt(),
|
||||||
job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
|
job.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
|
||||||
job.subtitleStatusOrDefault().name(), job.getSubtitleVtt(), job.getSubtitleError(),
|
job.subtitleStatusOrDefault().name(), job.getSubtitleVtt(), job.getSubtitleError(),
|
||||||
|
|||||||
@ -239,7 +239,7 @@
|
|||||||
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.videoTitle || 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}" data-subtitle-status="${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>
|
||||||
|
|||||||
@ -14,14 +14,32 @@ class ShortformServiceTest {
|
|||||||
void resumesCollectingSubtitleJobsAfterRestart() {
|
void resumesCollectingSubtitleJobsAfterRestart() {
|
||||||
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||||
ShortformSubtitleService subtitleService = mock(ShortformSubtitleService.class);
|
ShortformSubtitleService subtitleService = mock(ShortformSubtitleService.class);
|
||||||
|
ShortformVideoTitleClient titleClient = mock(ShortformVideoTitleClient.class);
|
||||||
ShortformJob job = mock(ShortformJob.class);
|
ShortformJob job = mock(ShortformJob.class);
|
||||||
when(job.getId()).thenReturn(34L);
|
when(job.getId()).thenReturn(34L);
|
||||||
when(repository.findBySubtitleStatus(SubtitleStatus.COLLECTING)).thenReturn(List.of(job));
|
when(repository.findBySubtitleStatus(SubtitleStatus.COLLECTING)).thenReturn(List.of(job));
|
||||||
ShortformService service = new ShortformService(
|
ShortformService service = new ShortformService(
|
||||||
repository, mock(ShortformOutputParser.class), subtitleService);
|
repository, mock(ShortformOutputParser.class), subtitleService, titleClient);
|
||||||
|
|
||||||
service.recoverInterruptedSubtitleCollections();
|
service.recoverInterruptedSubtitleCollections();
|
||||||
|
|
||||||
verify(subtitleService).collectAsync(34L);
|
verify(subtitleService).collectAsync(34L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fillsOriginalVideoTitlesForExistingJobs() {
|
||||||
|
ShortformJobRepository repository = mock(ShortformJobRepository.class);
|
||||||
|
ShortformVideoTitleClient titleClient = mock(ShortformVideoTitleClient.class);
|
||||||
|
ShortformJob job = ShortformJob.create(
|
||||||
|
"https://www.youtube.com/watch?v=YCX-XNhm4io", "YCX-XNhm4io");
|
||||||
|
when(repository.findByVideoTitleIsNull()).thenReturn(List.of(job));
|
||||||
|
when(titleClient.fetchTitle(job.getYoutubeUrl())).thenReturn("원본 유튜브 영상 제목");
|
||||||
|
ShortformService service = new ShortformService(repository,
|
||||||
|
mock(ShortformOutputParser.class), mock(ShortformSubtitleService.class), titleClient);
|
||||||
|
|
||||||
|
service.fillMissingVideoTitles();
|
||||||
|
|
||||||
|
org.assertj.core.api.Assertions.assertThat(job.getVideoTitle())
|
||||||
|
.isEqualTo("원본 유튜브 영상 제목");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
package com.hlab.yanalyst.domain.shortform;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.web.client.MockRestServiceServer;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||||
|
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||||
|
|
||||||
|
class ShortformVideoTitleClientTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsVideoTitleFromYoutubeOEmbed() {
|
||||||
|
RestTemplate restTemplate = new RestTemplate();
|
||||||
|
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
|
||||||
|
server.expect(requestTo("https://www.youtube.com/oembed?url=https%3A%2F%2Fyoutu.be%2FYCX-XNhm4io&format=json"))
|
||||||
|
.andRespond(withSuccess("{\"title\":\"원본 유튜브 영상 제목\",\"author_name\":\"채널\"}", APPLICATION_JSON));
|
||||||
|
|
||||||
|
String title = new ShortformVideoTitleClient(restTemplate)
|
||||||
|
.fetchTitle("https://youtu.be/YCX-XNhm4io");
|
||||||
|
|
||||||
|
assertThat(title).isEqualTo("원본 유튜브 영상 제목");
|
||||||
|
server.verify();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user