feat: 숏폼 큐 원본 영상 제목 표시

This commit is contained in:
hehihoho3@gmail.com 2026-08-25 15:15:18 +09:00
parent 51fcfffffa
commit 0e155c852c
8 changed files with 118 additions and 6 deletions

View File

@ -32,6 +32,10 @@ public class ShortformJob {
@Column(nullable = false, length = 20)
private String videoId;
/** YouTube 원본 영상 제목. 기존 행은 시작 시 자동으로 채운다. */
@Column(length = 500)
private String videoTitle;
@Enumerated(EnumType.STRING)
@Column(name = "content_category", length = 20)
private ShortformCategory category = ShortformCategory.ENTERTAINMENT;
@ -87,6 +91,12 @@ public class ShortformJob {
this.category = ShortformCategory.normalize(category);
}
public void updateVideoTitle(String videoTitle) {
if (videoTitle != null && !videoTitle.isBlank()) {
this.videoTitle = videoTitle.trim();
}
}
public ShortformCategory categoryOrDefault() {
return ShortformCategory.normalize(this.category);
}

View File

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

View File

@ -21,6 +21,7 @@ public class ShortformService {
private final ShortformJobRepository jobRepository;
private final ShortformOutputParser parser;
private final ShortformSubtitleService subtitleService;
private final ShortformVideoTitleClient titleClient;
/** 배포·재시작으로 중단된 비동기 자막 수집을 애플리케이션 기동 후 다시 시작한다. */
@EventListener(ApplicationReadyEvent.class)
@ -32,6 +33,17 @@ public class ShortformService {
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가 이미 있으면 그 작업을 그대로 반환한다(중복 방지). */
@Transactional
public JobDetail register(String youtubeUrl) {
@ -45,6 +57,9 @@ public class ShortformService {
ShortformJob job = jobRepository.findByVideoId(videoId)
.orElseGet(() -> jobRepository.save(ShortformJob.create(youtubeUrl.trim(), videoId, category)));
job.changeCategory(category);
if (job.getVideoTitle() == null) {
fillVideoTitle(job);
}
if (job.subtitleStatusOrDefault() != SubtitleStatus.READY
&& job.subtitleStatusOrDefault() != SubtitleStatus.COLLECTING) {
job.markSubtitleCollecting("자막 수집 대기 중");
@ -53,6 +68,10 @@ public class ShortformService {
return JobDetail.from(job);
}
private void fillVideoTitle(ShortformJob job) {
job.updateVideoTitle(titleClient.fetchTitle(job.getYoutubeUrl()));
}
private void scheduleSubtitleAfterCommit(Long jobId) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {

View File

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

View File

@ -17,11 +17,11 @@ public final class ShortformDtos {
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,
LocalDateTime createdAt, LocalDateTime completedAt) {
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.subtitleStatusOrDefault().name(), job.getSubtitleError(), job.getClips().size(),
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 rawOutput, String step1Output, String subtitleStatus,
String subtitleVtt, String subtitleError, List<ClipDto> clips) {
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.getCompletedAt(), job.getRawOutput(), job.getStep1Output(),
job.subtitleStatusOrDefault().name(), job.getSubtitleVtt(), job.getSubtitleError(),

View File

@ -239,7 +239,7 @@
el.innerHTML = `<div class="sf-job-head">
<img src="https://img.youtube.com/vi/${j.videoId}/mqdefault.jpg" alt="">
<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="sf-subtitle ${j.subtitleStatus}" data-subtitle-status="${j.subtitleStatus}"
title="${esc(j.subtitleError || '')}">자막 ${subtitleLabel(j)}</div>

View File

@ -14,14 +14,32 @@ class ShortformServiceTest {
void resumesCollectingSubtitleJobsAfterRestart() {
ShortformJobRepository repository = mock(ShortformJobRepository.class);
ShortformSubtitleService subtitleService = mock(ShortformSubtitleService.class);
ShortformVideoTitleClient titleClient = mock(ShortformVideoTitleClient.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);
repository, mock(ShortformOutputParser.class), subtitleService, titleClient);
service.recoverInterruptedSubtitleCollections();
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("원본 유튜브 영상 제목");
}
}

View File

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