From 0e155c852c53116b1d1c1705b9e6469791b5cffc Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 25 Aug 2026 15:15:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=8F=ED=8F=BC=20=ED=81=90=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EC=98=81=EC=83=81=20=EC=A0=9C=EB=AA=A9=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/shortform/ShortformJob.java | 10 ++++++ .../shortform/ShortformJobRepository.java | 2 ++ .../domain/shortform/ShortformService.java | 19 ++++++++++ .../shortform/ShortformVideoTitleClient.java | 36 +++++++++++++++++++ .../domain/shortform/dto/ShortformDtos.java | 8 ++--- src/main/resources/templates/shortform.html | 2 +- .../shortform/ShortformServiceTest.java | 20 ++++++++++- .../ShortformVideoTitleClientTest.java | 27 ++++++++++++++ 8 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/hlab/yanalyst/domain/shortform/ShortformVideoTitleClient.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/shortform/ShortformVideoTitleClientTest.java diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java index e03d624..2fbf119 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java @@ -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); } 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 16eb45a..86a6f84 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJobRepository.java @@ -12,6 +12,8 @@ public interface ShortformJobRepository extends JpaRepository findAllByOrderByCreatedAtDesc(); List findByStatusOrderByCreatedAtAsc(ShortformJobStatus status); List findBySubtitleStatus(SubtitleStatus subtitleStatus); + + List findByVideoTitleIsNull(); @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 7834990..cee4063 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java @@ -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 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() { diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformVideoTitleClient.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformVideoTitleClient.java new file mode 100644 index 0000000..30abbd1 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformVideoTitleClient.java @@ -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) {} +} diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java b/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java index 3f52d3b..1ec3876 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/dto/ShortformDtos.java @@ -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 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(), diff --git a/src/main/resources/templates/shortform.html b/src/main/resources/templates/shortform.html index 9ae0c61..07698cb 100644 --- a/src/main/resources/templates/shortform.html +++ b/src/main/resources/templates/shortform.html @@ -239,7 +239,7 @@ el.innerHTML = `
-
${esc(j.title || j.videoId)}
+
${esc(j.videoTitle || j.videoId)}
${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개
자막 ${subtitleLabel(j)}
diff --git a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformServiceTest.java b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformServiceTest.java index f9488a4..b79365b 100644 --- a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformServiceTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformServiceTest.java @@ -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("원본 유튜브 영상 제목"); + } } diff --git a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformVideoTitleClientTest.java b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformVideoTitleClientTest.java new file mode 100644 index 0000000..7ebf066 --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformVideoTitleClientTest.java @@ -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(); + } +}