From d70bb142c078e402c715e94f4ef12ceeba6e531c Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Fri, 4 Sep 2026 22:41:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9C=A0=ED=86=B5=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EC=99=80=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20=EA=B8=B8=EC=9D=B4=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/social/FacebookPageFeedClient.java | 28 ++- .../domain/social/SocialFeedService.java | 28 ++- .../yanalyst/domain/social/SocialVideo.java | 8 + .../domain/social/SocialVideoCandidate.java | 9 +- .../social/SocialVideoMetadataResolver.java | 79 +++++++++ .../domain/social/SocialVideoRepository.java | 3 +- .../domain/social/YtDlpSocialFeedClient.java | 7 +- .../templates/distribution-feed.html | 159 +++++++++++++++--- .../social/FacebookPageFeedClientTest.java | 10 +- .../domain/social/SocialFeedServiceTest.java | 34 +++- .../SocialVideoMetadataResolverTest.java | 21 +++ .../social/YtDlpSocialFeedClientTest.java | 2 + 12 files changed, 348 insertions(+), 40 deletions(-) create mode 100644 src/main/java/com/hlab/yanalyst/domain/social/SocialVideoMetadataResolver.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/social/SocialVideoMetadataResolverTest.java diff --git a/src/main/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClient.java b/src/main/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClient.java index 2f542d3..3e44176 100644 --- a/src/main/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClient.java +++ b/src/main/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClient.java @@ -29,6 +29,9 @@ public class FacebookPageFeedClient implements SocialFeedClient { private static final Pattern READER_REEL = Pattern.compile( "\\[!\\[[^]]*]\\((https?://[^)]+)\\)[^]]*]\\(https?://(?:www\\.)?facebook\\.com/reel/(\\d+)[^)]*\\)"); private static final Pattern READER_TITLE = Pattern.compile("(?m)^Title:\\s*(.+?)(?:\\s+Reels)?\\s*$"); + private static final Pattern KOREAN_DURATION = Pattern.compile("길이:\\s*(?:(\\d+)분,?\\s*)?(\\d+)초"); + private static final Pattern ENGLISH_DURATION = Pattern.compile( + "(?i)length:\\s*(?:(\\d+)\\s*minutes?,?\\s*)?(\\d+)\\s*seconds?"); /** Facebook가 데이터센터별로 빈 피드를 돌려주는 경우가 있어 공식 로케일 호스트를 순서대로 시도한다. */ private static final List RENDERER_HOSTS = List.of( "www.facebook.com", "ko-kr.facebook.com", "en-gb.facebook.com"); @@ -188,8 +191,9 @@ public class FacebookPageFeedClient implements SocialFeedClient { Element timeElement = post.selectFirst("abbr[data-utime]"); LocalDateTime publishedAt = parseUnixTime(timeElement == null ? null : timeElement.attr("data-utime")); String thumbnail = findThumbnail(post); + Integer durationSec = findDuration(post); unique.put(id, new SocialVideoCandidate(id, "https://www.facebook.com/reel/" + id, - title.isBlank() ? "(제목 없음)" : title, thumbnail, publishedAt)); + title.isBlank() ? "(제목 없음)" : title, thumbnail, publishedAt, durationSec)); if (channelTitle == null) { Element titleNode = post.selectFirst("._2_79, a[title]"); @@ -214,7 +218,7 @@ public class FacebookPageFeedClient implements SocialFeedClient { String id = reelMatcher.group(2); String title = (channelTitle == null ? "Facebook" : channelTitle) + " · Reel " + id; unique.putIfAbsent(id, new SocialVideoCandidate(id, - "https://www.facebook.com/reel/" + id, title, thumbnail, null)); + "https://www.facebook.com/reel/" + id, title, thumbnail, null, null)); } return new SocialFeedBatch(channelTitle, null, new ArrayList<>(unique.values())); } @@ -228,6 +232,26 @@ public class FacebookPageFeedClient implements SocialFeedClient { return null; } + private static Integer findDuration(Element post) { + for (Element element : post.select("[aria-label]")) { + String label = element.attr("aria-label"); + Integer seconds = parseDurationLabel(label, KOREAN_DURATION); + if (seconds != null) return seconds; + seconds = parseDurationLabel(label, ENGLISH_DURATION); + if (seconds != null) return seconds; + } + return null; + } + + private static Integer parseDurationLabel(String label, Pattern pattern) { + Matcher matcher = pattern.matcher(label == null ? "" : label); + if (!matcher.find()) return null; + int minutes = matcher.group(1) == null ? 0 : Integer.parseInt(matcher.group(1)); + int seconds = Integer.parseInt(matcher.group(2)); + int total = minutes * 60 + seconds; + return total > 0 ? total : null; + } + private static LocalDateTime parseUnixTime(String value) { if (value == null || value.isBlank()) return null; try { diff --git a/src/main/java/com/hlab/yanalyst/domain/social/SocialFeedService.java b/src/main/java/com/hlab/yanalyst/domain/social/SocialFeedService.java index 8c673a6..bbf1123 100644 --- a/src/main/java/com/hlab/yanalyst/domain/social/SocialFeedService.java +++ b/src/main/java/com/hlab/yanalyst/domain/social/SocialFeedService.java @@ -25,6 +25,7 @@ public class SocialFeedService { private final SocialVideoRepository videoRepository; private final List clients; private final SocialFeedAlertService alertService; + private final SocialVideoMetadataResolver metadataResolver; @Value("${hlab.social-feed.enabled:true}") private boolean enabled; @Value("${hlab.social-feed.seed.phutunet-enabled:true}") private boolean phutunetSeedEnabled; @@ -42,13 +43,14 @@ public class SocialFeedService { } public record VideoDto(Long id, String externalVideoId, String url, String title, String thumbnailUrl, + Integer durationSec, LocalDateTime publishedAt, LocalDateTime collectedAt, String channelTitle, String platform, String topic) { static VideoDto from(SocialVideo video) { SocialChannel channel = video.getChannel(); return new VideoDto(video.getId(), video.getExternalVideoId(), video.getUrl(), video.getTitle(), - video.getThumbnailUrl(), video.getPublishedAt(), video.getCollectedAt(), channel.getTitle(), - channel.getPlatform().name(), channel.getTopic().name()); + video.getThumbnailUrl(), video.getDurationSec(), video.getPublishedAt(), video.getCollectedAt(), + channel.getTitle(), channel.getPlatform().name(), channel.getTopic().name()); } } @@ -134,12 +136,28 @@ public class SocialFeedService { channel.updateMetadata(batch.channelTitle(), batch.externalChannelId()); List inserted = new ArrayList<>(); - for (SocialVideoCandidate candidate : batch.videos()) { + for (SocialVideoCandidate original : batch.videos()) { + SocialVideoCandidate candidate = original; if (candidate.externalVideoId() == null || candidate.externalVideoId().isBlank() - || candidate.url() == null || candidate.url().isBlank() - || videoRepository.existsByChannelIdAndExternalVideoId(channelId, candidate.externalVideoId())) { + || candidate.url() == null || candidate.url().isBlank()) { continue; } + Optional existing = videoRepository.findByChannelIdAndExternalVideoId( + channelId, candidate.externalVideoId()); + if (existing.isPresent()) { + SocialVideo stored = existing.get(); + if (stored.getDurationSec() == null) { + Integer durationSec = candidate.durationSec() == null + ? metadataResolver.resolveDuration(candidate) + : candidate.durationSec(); + stored.fillDuration(durationSec); + if (stored.getDurationSec() != null) videoRepository.save(stored); + } + continue; + } + if (candidate.durationSec() == null) { + candidate = candidate.withDurationSec(metadataResolver.resolveDuration(candidate)); + } inserted.add(videoRepository.save(SocialVideo.from(channel, candidate))); } channel.collectionSucceeded(inserted.size()); diff --git a/src/main/java/com/hlab/yanalyst/domain/social/SocialVideo.java b/src/main/java/com/hlab/yanalyst/domain/social/SocialVideo.java index 5e3cbb1..ae03d62 100644 --- a/src/main/java/com/hlab/yanalyst/domain/social/SocialVideo.java +++ b/src/main/java/com/hlab/yanalyst/domain/social/SocialVideo.java @@ -42,6 +42,9 @@ public class SocialVideo { @Column(name = "published_at") private LocalDateTime publishedAt; + @Column(name = "duration_sec") + private Integer durationSec; + @CreatedDate @Column(name = "collected_at", updatable = false) private LocalDateTime collectedAt; @@ -54,6 +57,11 @@ public class SocialVideo { video.title = candidate.title() == null || candidate.title().isBlank() ? "(제목 없음)" : candidate.title(); video.thumbnailUrl = candidate.thumbnailUrl(); video.publishedAt = candidate.publishedAt(); + video.durationSec = candidate.durationSec(); return video; } + + public void fillDuration(Integer value) { + if (durationSec == null && value != null && value > 0) durationSec = value; + } } diff --git a/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoCandidate.java b/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoCandidate.java index fa243d2..a0d0c3c 100644 --- a/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoCandidate.java +++ b/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoCandidate.java @@ -7,5 +7,10 @@ public record SocialVideoCandidate( String url, String title, String thumbnailUrl, - LocalDateTime publishedAt -) { } + LocalDateTime publishedAt, + Integer durationSec +) { + SocialVideoCandidate withDurationSec(Integer value) { + return new SocialVideoCandidate(externalVideoId, url, title, thumbnailUrl, publishedAt, value); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoMetadataResolver.java b/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoMetadataResolver.java new file mode 100644 index 0000000..2c47fd0 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoMetadataResolver.java @@ -0,0 +1,79 @@ +package com.hlab.yanalyst.domain.social; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 채널 목록 응답에 빠진 영상 길이를 개별 영상 메타데이터에서 보충한다. */ +@Slf4j +@Component +public class SocialVideoMetadataResolver { + private static final Pattern DURATION_LINE = Pattern.compile("(?m)^([0-9]+(?:\\.[0-9]+)?)\\s*$"); + + @Value("${ytdlp.bin:yt-dlp}") private String ytdlpBin; + @Value("${hlab.social-feed.metadata-timeout-seconds:45}") private long timeoutSeconds; + @Value("${hlab.social-feed.cookies-file:}") private String cookiesFile; + + public Integer resolveDuration(SocialVideoCandidate candidate) { + if (candidate.durationSec() != null && candidate.durationSec() > 0) return candidate.durationSec(); + List command = buildCommand(ytdlpBin, cookiesFile, candidate.url()); + Process process = null; + try { + process = new ProcessBuilder(command).redirectErrorStream(true).start(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Process running = process; + Thread drainer = Thread.ofVirtual().start(() -> { + try (var in = running.getInputStream()) { in.transferTo(output); } + catch (Exception ignored) { } + }); + if (!process.waitFor(Math.max(1, timeoutSeconds), TimeUnit.SECONDS)) { + process.destroyForcibly(); + log.warn("[SocialFeed] 영상 길이 조회 시간 초과: {}", candidate.url()); + return null; + } + drainer.join(2_000); + String text = output.toString(StandardCharsets.UTF_8); + if (process.exitValue() != 0) { + log.warn("[SocialFeed] 영상 길이 조회 실패: url={}, output={}", candidate.url(), tail(text)); + return null; + } + return parseDurationSeconds(text); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } catch (Exception e) { + log.warn("[SocialFeed] 영상 길이 조회 실행 실패: url={}, error={}", candidate.url(), e.getMessage()); + return null; + } finally { + if (process != null && process.isAlive()) process.destroyForcibly(); + } + } + + static List buildCommand(String bin, String cookiesFile, String url) { + List command = new ArrayList<>(List.of(bin, "--skip-download", "--no-playlist", + "--no-warnings", "--print", "%(duration)s")); + if (cookiesFile != null && !cookiesFile.isBlank()) command.addAll(List.of("--cookies", cookiesFile.trim())); + command.add(url); + return command; + } + + static Integer parseDurationSeconds(String output) { + Matcher matcher = DURATION_LINE.matcher(output == null ? "" : output); + if (!matcher.find()) return null; + double seconds = Double.parseDouble(matcher.group(1)); + return seconds > 0 ? Math.max(1, (int) Math.round(seconds)) : null; + } + + private static String tail(String output) { + String clean = output == null ? "" : output.strip(); + return clean.length() <= 500 ? clean : clean.substring(clean.length() - 500); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoRepository.java b/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoRepository.java index e5b343e..783e504 100644 --- a/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/social/SocialVideoRepository.java @@ -7,9 +7,10 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface SocialVideoRepository extends JpaRepository { - boolean existsByChannelIdAndExternalVideoId(Long channelId, String externalVideoId); + Optional findByChannelIdAndExternalVideoId(Long channelId, String externalVideoId); @Query("select v from SocialVideo v join fetch v.channel c where c.topic = :topic " + "order by coalesce(v.publishedAt, v.collectedAt) desc") diff --git a/src/main/java/com/hlab/yanalyst/domain/social/YtDlpSocialFeedClient.java b/src/main/java/com/hlab/yanalyst/domain/social/YtDlpSocialFeedClient.java index ef64de5..60e1746 100644 --- a/src/main/java/com/hlab/yanalyst/domain/social/YtDlpSocialFeedClient.java +++ b/src/main/java/com/hlab/yanalyst/domain/social/YtDlpSocialFeedClient.java @@ -80,7 +80,7 @@ public class YtDlpSocialFeedClient implements SocialFeedClient { String title = firstText(entry, "title", "description"); videos.add(new SocialVideoCandidate(id, url, title == null || title.isBlank() ? "(제목 없음)" : title, - thumbnail(entry), publishedAt(entry))); + thumbnail(entry), publishedAt(entry), durationSec(entry))); if (videos.size() >= limit) break; } return new SocialFeedBatch(channelTitle, channelId, videos); @@ -114,6 +114,11 @@ public class YtDlpSocialFeedClient implements SocialFeedClient { return null; } + private static Integer durationSec(JsonNode entry) { + double seconds = entry.path("duration").asDouble(0); + return seconds > 0 ? Math.max(1, (int) Math.round(seconds)) : null; + } + private static String canonicalUrl(SocialPlatform platform, String id) { return switch (platform) { case YOUTUBE -> "https://www.youtube.com/watch?v=" + id; diff --git a/src/main/resources/templates/distribution-feed.html b/src/main/resources/templates/distribution-feed.html index 28a9b78..355d4af 100644 --- a/src/main/resources/templates/distribution-feed.html +++ b/src/main/resources/templates/distribution-feed.html @@ -9,33 +9,81 @@

유통 피드

Facebook · Instagram · TikTok · YouTube 유통 채널의 새 영상을 모으고 텔레그램으로 알립니다.

- +
+ + +
- - - @@ -43,7 +91,8 @@ @@ -54,12 +103,74 @@ async function api(url,options){const r=await fetch(url,options);const j=await r.json().catch(()=>({}));if(!r.ok||j.success===false)throw new Error(j.message||`HTTP ${r.status}`);return j.data} let toastTimer;function toast(message,error){const el=document.getElementById('toast');el.textContent=message;el.classList.toggle('err',!!error);el.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),3500)} function ago(iso){if(!iso)return '수집 대기';const ms=Date.now()-new Date(iso).getTime(),min=Math.max(0,Math.floor(ms/60000));if(min<60)return `${min}분 전`;const h=Math.floor(min/60);if(h<24)return `${h}시간 전`;return `${Math.floor(h/24)}일 전`} - function date(iso){return iso?new Date(iso).toLocaleString('ko-KR',{month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}):'게시 시각 미상'} + function duration(sec){if(sec==null||Number(sec)<=0)return '';const total=Math.round(Number(sec)),h=Math.floor(total/3600),m=Math.floor(total%3600/60),s=total%60;return h?`${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`:`${m}:${String(s).padStart(2,'0')}`} + const platformName=p=>({FACEBOOK:'Facebook',INSTAGRAM:'Instagram',TIKTOK:'TikTok',YOUTUBE:'YouTube'}[p]||p); + let videos=[]; + let lastFocused=null; + let currentPreviewUrl=''; + async function loadChannels(){const box=document.getElementById('channelList');try{const list=await api(`${API}/channels?topic=DISTRIBUTION`);box.innerHTML=list.length?list.map(c=>``).join(''):''}catch(e){box.innerHTML=``}} - async function loadVideos(){const grid=document.getElementById('videoGrid'),empty=document.getElementById('emptyState');try{const list=await api(`${API}/videos?topic=DISTRIBUTION`);document.getElementById('videoCount').textContent=`${list.length}건`;empty.classList.toggle('hidden',list.length>0);grid.innerHTML=list.map(v=>``).join('');if(window.lucide)lucide.createIcons()}catch(e){toast('영상을 불러오지 못했습니다: '+e.message,true)}} + async function loadVideos(){try{videos=await api(`${API}/videos?topic=DISTRIBUTION`)||[];renderVideos()}catch(e){toast('영상을 불러오지 못했습니다: '+e.message,true)}} + function renderVideos(){ + const platform=document.getElementById('platformFilter').value; + const list=platform?videos.filter(v=>v.platform===platform):videos; + const grid=document.getElementById('videoGrid'),empty=document.getElementById('emptyState'); + document.getElementById('videoCount').textContent=`${list.length}건`; + empty.classList.toggle('hidden',list.length>0); + grid.innerHTML=list.map(v=>{ + const dur=duration(v.durationSec); + return ``; + }).join(''); + if(window.lucide)lucide.createIcons(); + } + + function embedUrl(v){ + if(v.platform==='YOUTUBE')return `https://www.youtube.com/embed/${encodeURIComponent(v.externalVideoId)}?autoplay=1&rel=0`; + if(v.platform==='TIKTOK')return `https://www.tiktok.com/player/v1/${encodeURIComponent(v.externalVideoId)}?autoplay=1&loop=0`; + if(v.platform==='INSTAGRAM')return v.url.replace(/\/?$/,'/')+'embed/'; + return `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(v.url)}&show_text=false&autoplay=true&width=500`; + } + function openVideo(id){ + const v=videos.find(item=>item.id===id);if(!v)return; + lastFocused=document.activeElement;currentPreviewUrl=v.url; + document.getElementById('previewTitle').textContent=v.title; + document.getElementById('previewUrl').value=v.url; + document.getElementById('previewOriginal').href=v.url; + const dur=duration(v.durationSec); + document.getElementById('previewMeta').textContent=`${v.channelTitle} · ${platformName(v.platform)} · ${ago(v.publishedAt||v.collectedAt)}${dur?' · '+dur:''}`; + const frame=document.getElementById('previewFrame'); + frame.classList.toggle('portrait',v.platform!=='YOUTUBE'); + frame.innerHTML=``; + document.getElementById('videoModal').classList.add('open'); + document.body.style.overflow='hidden'; + if(window.lucide)lucide.createIcons(); + document.getElementById('previewClose').focus(); + } + function closeVideo(){const modal=document.getElementById('videoModal');if(!modal.classList.contains('open'))return;modal.classList.remove('open');document.getElementById('previewFrame').innerHTML='';document.body.style.overflow='';currentPreviewUrl='';if(lastFocused&&lastFocused.focus)lastFocused.focus();lastFocused=null} + function openChannels(){lastFocused=document.activeElement;document.getElementById('channelModal').classList.add('open');document.body.style.overflow='hidden';document.getElementById('channelModalClose').focus()} + function closeChannels(){const modal=document.getElementById('channelModal');if(!modal.classList.contains('open'))return;modal.classList.remove('open');document.body.style.overflow='';if(lastFocused&&lastFocused.focus)lastFocused.focus();lastFocused=null} + + async function copyText(value){try{await navigator.clipboard.writeText(value);return true}catch(e){const input=document.createElement('textarea');input.value=value;input.setAttribute('readonly','');input.style.position='fixed';input.style.opacity='0';document.body.appendChild(input);input.select();const copied=document.execCommand('copy');input.remove();return copied}} + async function copyVideoUrl(id){const v=videos.find(item=>item.id===id);if(!v)return;const copied=await copyText(v.url);toast(copied?'영상 URL을 복사했습니다':'URL 복사에 실패했습니다',!copied)} + async function copyPreviewUrl(){if(!currentPreviewUrl)return;const copied=await copyText(currentPreviewUrl);toast(copied?'영상 URL을 복사했습니다':'URL 복사에 실패했습니다',!copied)} async function addChannel(){const input=document.getElementById('channelUrl'),button=document.getElementById('addBtn'),url=input.value.trim();if(!url){input.focus();return}button.disabled=true;try{const c=await api(`${API}/channels`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,topic:'DISTRIBUTION',notificationEnabled:document.getElementById('notifyEnabled').checked})});input.value='';toast(`${c.title} 채널을 등록했습니다 · 첫 수집을 시작합니다`);await loadChannels();await collectNow()}catch(e){toast('등록 실패: '+e.message,true)}finally{button.disabled=false}} async function removeChannel(id){if(!confirm('이 채널과 수집 영상을 삭제할까요?'))return;try{await api(`${API}/channels/${id}`,{method:'DELETE'});toast('채널을 삭제했습니다');await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('삭제 실패: '+e.message,true)}} async function collectNow(){const button=document.getElementById('collectBtn'),original=button.innerHTML;button.disabled=true;button.textContent='수집 중…';try{const r=await api(`${API}/collect?topic=DISTRIBUTION`,{method:'POST'});toast(`수집 완료 · 새 영상 ${r.savedVideos}건 · 알림 ${r.alerted}건${r.failed?` · 실패 ${r.failed}개`:''}`,r.failed>0);await Promise.all([loadChannels(),loadVideos()])}catch(e){toast('수집 실패: '+e.message,true)}finally{button.disabled=false;button.innerHTML=original;if(window.lucide)lucide.createIcons()}} + document.addEventListener('keydown',event=>{if(event.key!=='Escape')return;if(document.getElementById('videoModal').classList.contains('open'))closeVideo();else closeChannels()}); Promise.all([loadChannels(),loadVideos()]); diff --git a/src/test/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClientTest.java b/src/test/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClientTest.java index a01a05b..81d9be6 100644 --- a/src/test/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClientTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/social/FacebookPageFeedClientTest.java @@ -13,13 +13,17 @@ class FacebookPageFeedClientTest { String markup = """
Phutu Crafts
- 15시간 전 + + 15시간 전 +

How to Fix a Broken Outlet

댓글
- 6일 + + 6일 +
An Outdoor Light Hidden Inside a Tree
"""; @@ -32,7 +36,9 @@ class FacebookPageFeedClientTest { assertThat(batch.videos().get(0).url()).isEqualTo("https://www.facebook.com/reel/2234599224051571"); assertThat(batch.videos().get(0).title()).isEqualTo("How to Fix a Broken Outlet"); assertThat(batch.videos().get(0).thumbnailUrl()).isEqualTo("https://cdn.example/thumb.jpg"); + assertThat(batch.videos().get(0).durationSec()).isEqualTo(16); assertThat(batch.videos().get(0).publishedAt()).isNotNull(); + assertThat(batch.videos().get(1).durationSec()).isEqualTo(178); } @Test diff --git a/src/test/java/com/hlab/yanalyst/domain/social/SocialFeedServiceTest.java b/src/test/java/com/hlab/yanalyst/domain/social/SocialFeedServiceTest.java index 64264a1..486210c 100644 --- a/src/test/java/com/hlab/yanalyst/domain/social/SocialFeedServiceTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/social/SocialFeedServiceTest.java @@ -17,6 +17,7 @@ class SocialFeedServiceTest { SocialVideoRepository videos = mock(SocialVideoRepository.class); SocialFeedClient client = mock(SocialFeedClient.class); SocialFeedAlertService alerts = mock(SocialFeedAlertService.class); + SocialVideoMetadataResolver metadata = mock(SocialVideoMetadataResolver.class); SocialChannel channel = SocialChannel.register("https://facebook.com/phutunet/reels/", "DISTRIBUTION", true); SocialVideoCandidate existing = candidate("old"); SocialVideoCandidate newlyPublished = candidate("new"); @@ -26,10 +27,12 @@ class SocialFeedServiceTest { when(client.fetch(channel)).thenReturn( new SocialFeedBatch("Phutu Crafts", null, List.of(existing)), new SocialFeedBatch("Phutu Crafts", null, List.of(newlyPublished))); - when(videos.existsByChannelIdAndExternalVideoId(eq(1L), anyString())).thenReturn(false); + when(videos.findByChannelIdAndExternalVideoId(eq(1L), anyString())) + .thenReturn(java.util.Optional.empty()); + when(metadata.resolveDuration(any())).thenReturn(17); when(videos.save(any(SocialVideo.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(alerts.notifyNewVideos(eq(channel), anyList())).thenReturn(1); - SocialFeedService service = new SocialFeedService(channels, videos, List.of(client), alerts); + SocialFeedService service = new SocialFeedService(channels, videos, List.of(client), alerts, metadata); SocialFeedService.CollectionResult baseline = service.collectOne(1L); SocialFeedService.CollectionResult next = service.collectOne(1L); @@ -41,8 +44,33 @@ class SocialFeedServiceTest { verify(alerts, times(1)).notifyNewVideos(eq(channel), anyList()); } + @Test + void backfillsMissingDurationOnPreviouslyCollectedVideo() { + SocialChannelRepository channels = mock(SocialChannelRepository.class); + SocialVideoRepository videos = mock(SocialVideoRepository.class); + SocialFeedClient client = mock(SocialFeedClient.class); + SocialFeedAlertService alerts = mock(SocialFeedAlertService.class); + SocialVideoMetadataResolver metadata = mock(SocialVideoMetadataResolver.class); + SocialChannel channel = SocialChannel.register("https://facebook.com/phutunet/reels/", "DISTRIBUTION", true); + SocialVideoCandidate candidate = candidate("existing"); + SocialVideo stored = SocialVideo.from(channel, candidate); + + when(channels.findById(1L)).thenReturn(java.util.Optional.of(channel)); + when(client.supports(SocialPlatform.FACEBOOK)).thenReturn(true); + when(client.fetch(channel)).thenReturn(new SocialFeedBatch("Phutu Crafts", null, List.of(candidate))); + when(videos.findByChannelIdAndExternalVideoId(1L, "existing")) + .thenReturn(java.util.Optional.of(stored)); + when(metadata.resolveDuration(candidate)).thenReturn(17); + SocialFeedService service = new SocialFeedService(channels, videos, List.of(client), alerts, metadata); + + service.collectOne(1L); + + assertThat(stored.getDurationSec()).isEqualTo(17); + verify(videos).save(stored); + } + private static SocialVideoCandidate candidate(String id) { return new SocialVideoCandidate(id, "https://facebook.com/reel/" + id, id, null, - LocalDateTime.now()); + LocalDateTime.now(), null); } } diff --git a/src/test/java/com/hlab/yanalyst/domain/social/SocialVideoMetadataResolverTest.java b/src/test/java/com/hlab/yanalyst/domain/social/SocialVideoMetadataResolverTest.java new file mode 100644 index 0000000..ef5cc9a --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/social/SocialVideoMetadataResolverTest.java @@ -0,0 +1,21 @@ +package com.hlab.yanalyst.domain.social; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SocialVideoMetadataResolverTest { + + @Test + void parsesAndRoundsYtDlpDuration() { + assertThat(SocialVideoMetadataResolver.parseDurationSeconds("16.81\n")).isEqualTo(17); + assertThat(SocialVideoMetadataResolver.parseDurationSeconds("NA\n")).isNull(); + } + + @Test + void buildsArgumentSafeMetadataCommand() { + assertThat(SocialVideoMetadataResolver.buildCommand("yt-dlp", "", "https://facebook.com/reel/123")) + .containsExactly("yt-dlp", "--skip-download", "--no-playlist", "--no-warnings", + "--print", "%(duration)s", "https://facebook.com/reel/123"); + } +} diff --git a/src/test/java/com/hlab/yanalyst/domain/social/YtDlpSocialFeedClientTest.java b/src/test/java/com/hlab/yanalyst/domain/social/YtDlpSocialFeedClientTest.java index 5b8b63f..74ed2d0 100644 --- a/src/test/java/com/hlab/yanalyst/domain/social/YtDlpSocialFeedClientTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/social/YtDlpSocialFeedClientTest.java @@ -12,6 +12,7 @@ class YtDlpSocialFeedClientTest { String json = """ {"channel":"유통 채널","channel_id":"UC-shop","entries":[ {"id":"abcdefghijk","title":"새 상품 영상","url":"abcdefghijk","timestamp":1788448574, + "duration":42.4, "thumbnails":[{"url":"https://cdn.example/small.jpg"},{"url":"https://cdn.example/large.jpg"}]} ]} """; @@ -24,6 +25,7 @@ class YtDlpSocialFeedClientTest { assertThat(batch.videos()).singleElement().satisfies(video -> { assertThat(video.url()).isEqualTo("https://www.youtube.com/watch?v=abcdefghijk"); assertThat(video.thumbnailUrl()).isEqualTo("https://cdn.example/large.jpg"); + assertThat(video.durationSec()).isEqualTo(42); assertThat(video.publishedAt()).isNotNull(); }); }