feat: 소재 피드에서 영상을 사이트 안에서 재생

썸네일·제목을 누르면 YouTube 새 탭으로 나가던 걸 레이어 팝업 재생으로 바꿨다.
쇼츠는 세로(9:16), 롱폼은 가로(16:9)로 비율을 맞추고, 팝업 안에 숏폼 큐 등록과
YouTube 열기를 함께 뒀다. Escape·배경 클릭으로 닫히고 닫을 때 재생이 멈춘다.

임베드를 막아둔 채널이 많다는 걸 실측했다 — 소재 원본 9건 중 4건이 차단이고
채널 단위로 갈린다(짠한형 전부 차단, 뜬뜬·스프 전부 가능). 그대로 두면 절반이
"동영상을 재생할 수 없음" 화면을 보게 되므로:

- 수집할 때 videos.list 에 status 파트를 붙여 embeddable 을 저장한다(쿼터 동일).
- 차단된 영상은 팝업 대신 바로 YouTube 새 탭으로 연다.
- 차단 영상 썸네일에 외부링크 아이콘을 띄워 눌렀을 때 나가는 걸 예고한다.
- embeddable 이 null(미수집)이면 일단 재생을 시도한다 — 팝업 안에 YouTube 링크가
  있어 막혀도 빠져나갈 수 있다.

브라우저에서 확인: 재생 가능 영상은 팝업에서 실제 재생됐고(썸네일·제목 클릭 모두),
차단 채널 카드에만 외부링크 아이콘이 붙었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-04 10:16:52 +09:00
parent e9d3f9a9f4
commit 13dd61d80c
5 changed files with 149 additions and 13 deletions

View File

@ -307,7 +307,8 @@ public class ChannelService {
private int upsertVideos(Channel channel, List<String> videoIds, String source, private int upsertVideos(Channel channel, List<String> videoIds, String source,
Boolean shortsOnly, LocalDateTime publishedAfter) { Boolean shortsOnly, LocalDateTime publishedAfter) {
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos") String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
.queryParam("part", "snippet,statistics,contentDetails") // status 임베드 가능 여부(embeddable) 때문에 필요하다. part 늘려도 쿼터는 그대로다.
.queryParam("part", "snippet,statistics,contentDetails,status")
.queryParam("id", String.join(",", videoIds)) .queryParam("id", String.join(",", videoIds))
.queryParam("key", youtubeApiKey) .queryParam("key", youtubeApiKey)
.toUriString(); .toUriString();
@ -350,6 +351,9 @@ public class ChannelService {
// 해시태그는 시드 역분석( 채널 소재 원본 채널 후보) 재료가 된다. // 해시태그는 시드 역분석( 채널 소재 원본 채널 후보) 재료가 된다.
String hashtags = HashtagExtractor.join( String hashtags = HashtagExtractor.join(
HashtagExtractor.extract(snippet.path("description").asText("") + " " + title)); HashtagExtractor.extract(snippet.path("description").asText("") + " " + title));
// 임베드 차단 채널이 많아(방송사·연예 채널) 미리 알아둬야 헛클릭을 막는다
final Boolean embeddable = item.path("status").has("embeddable")
? item.path("status").path("embeddable").asBoolean() : null;
channelVideoRepository.findByVideoId(videoId) channelVideoRepository.findByVideoId(videoId)
.ifPresentOrElse(v -> { .ifPresentOrElse(v -> {
@ -357,6 +361,7 @@ public class ChannelService {
v.applyMetrics(durationSec, isShorts, viewsPerHour); v.applyMetrics(durationSec, isShorts, viewsPerHour);
v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source); v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
v.applyHashtags(hashtags); v.applyHashtags(hashtags);
v.applyEmbeddable(embeddable);
channelVideoRepository.save(v); channelVideoRepository.save(v);
}, () -> { }, () -> {
ChannelVideo newVideo = ChannelVideo.builder() ChannelVideo newVideo = ChannelVideo.builder()
@ -372,6 +377,7 @@ public class ChannelService {
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour); newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source); newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
newVideo.applyHashtags(hashtags); newVideo.applyHashtags(hashtags);
newVideo.applyEmbeddable(embeddable);
channelVideoRepository.save(newVideo); channelVideoRepository.save(newVideo);
}); });
saved++; saved++;

View File

@ -82,6 +82,14 @@ public class ChannelVideo {
@Column(name = "hashtags", columnDefinition = "TEXT") @Column(name = "hashtags", columnDefinition = "TEXT")
private String hashtags; private String hashtags;
/**
* 다른 사이트에 임베드할 있는가(YouTube status.embeddable).
* 방송사·연예 채널은 막아둔 경우가 많아(실측 9건 4건) 미리 알아야 헛클릭을 막는다.
* null 이면 아직 모름 일단 재생을 시도한다.
*/
@Column(name = "embeddable")
private Boolean embeddable;
/** /**
* 인물 추적으로 걸린 영상이면 인물명. 인물 탭은 source 아니라 값으로 조회하므로, * 인물 추적으로 걸린 영상이면 인물명. 인물 탭은 source 아니라 값으로 조회하므로,
* 소스 채널에서 이미 수집한 영상이 인물 검색에도 걸리면 모두에 나타난다. * 소스 채널에서 이미 수집한 영상이 인물 검색에도 걸리면 모두에 나타난다.
@ -179,6 +187,11 @@ public class ChannelVideo {
this.hashtags = hashtags; this.hashtags = hashtags;
} }
/** 임베드 가능 여부를 기록한다. */
public void applyEmbeddable(Boolean embeddable) {
this.embeddable = embeddable;
}
/** Gemini 가 영상을 보고 만든 내용 요약을 저장한다(영상당 1회). */ /** Gemini 가 영상을 보고 만든 내용 요약을 저장한다(영상당 1회). */
public void applyContextSummary(String contextSummary) { public void applyContextSummary(String contextSummary) {
this.contextSummary = contextSummary; this.contextSummary = contextSummary;

View File

@ -138,7 +138,8 @@ public class PersonCollectionService {
} }
if (videoIds.isEmpty()) return 0; if (videoIds.isEmpty()) return 0;
List<PersonPicks.Found> found = fetchDetails(videoIds); Map<String, Boolean> embeddable = new LinkedHashMap<>();
List<PersonPicks.Found> found = fetchDetails(videoIds, embeddable);
List<PersonPicks.Found> picks = PersonPicks.keep(found, exclude, publishedAfter, minViewsPerHour); List<PersonPicks.Found> picks = PersonPicks.keep(found, exclude, publishedAfter, minViewsPerHour);
int saved = 0; int saved = 0;
@ -151,19 +152,24 @@ public class PersonCollectionService {
v.update(f.title(), v.getThumbnailUrl(), f.viewCount(), v.getLikeCount()); v.update(f.title(), v.getThumbnailUrl(), f.viewCount(), v.getLikeCount());
v.applyMetrics(f.durationSec(), VideoMetrics.isShorts(f.durationSec()), vph); v.applyMetrics(f.durationSec(), VideoMetrics.isShorts(f.durationSec()), vph);
v.applyMatchedPerson(person); v.applyMatchedPerson(person);
v.applyEmbeddable(embeddable.get(f.videoId()));
channelVideoRepository.save(v); channelVideoRepository.save(v);
}, () -> channelVideoRepository.save(ChannelVideo.fromPersonSearch( }, () -> {
f.videoId(), f.title(), thumbnailOf(f.videoId()), f.publishedAt(), f.viewCount(), ChannelVideo nv = ChannelVideo.fromPersonSearch(
f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person))); f.videoId(), f.title(), thumbnailOf(f.videoId()), f.publishedAt(), f.viewCount(),
f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person);
nv.applyEmbeddable(embeddable.get(f.videoId()));
channelVideoRepository.save(nv);
});
saved++; saved++;
} }
return saved; return saved;
} }
/** videos.list 로 길이·조회수·업로드일을 채운다(검색 결과에는 길이가 없다). */ /** videos.list 로 길이·조회수·업로드일을 채운다(검색 결과에는 길이가 없다). */
private List<PersonPicks.Found> fetchDetails(List<String> videoIds) { private List<PersonPicks.Found> fetchDetails(List<String> videoIds, Map<String, Boolean> embeddableOut) {
URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos") URI uri = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
.queryParam("part", "snippet,contentDetails,statistics") .queryParam("part", "snippet,contentDetails,statistics,status")
.queryParam("id", String.join(",", videoIds)) .queryParam("id", String.join(",", videoIds))
.queryParam("key", youtubeApiKey) .queryParam("key", youtubeApiKey)
.build().encode().toUri(); .build().encode().toUri();
@ -180,6 +186,9 @@ public class PersonCollectionService {
LocalDateTime publishedAt = LocalDateTime.parse( LocalDateTime publishedAt = LocalDateTime.parse(
snippet.path("publishedAt").asText(), DateTimeFormatter.ISO_DATE_TIME); snippet.path("publishedAt").asText(), DateTimeFormatter.ISO_DATE_TIME);
long views = item.path("statistics").path("viewCount").asLong(0); long views = item.path("statistics").path("viewCount").asLong(0);
if (item.path("status").has("embeddable")) {
embeddableOut.put(item.path("id").asText(), item.path("status").path("embeddable").asBoolean());
}
out.add(new PersonPicks.Found( out.add(new PersonPicks.Found(
item.path("id").asText(), snippet.path("title").asText(""), item.path("id").asText(), snippet.path("title").asText(""),
snippet.path("channelId").asText(null), snippet.path("channelTitle").asText(""), snippet.path("channelId").asText(null), snippet.path("channelTitle").asText(""),

View File

@ -26,6 +26,8 @@ public record FeedItemDto(
String source, String source,
/** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */ /** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */
String matchedPerson, String matchedPerson,
/** 사이트 안에서 재생 가능한가. null 이면 아직 모름 — 일단 재생을 시도한다. */
Boolean embeddable,
/** 업로드 24시간 이내 — 선점 골든타임. */ /** 업로드 24시간 이내 — 선점 골든타임. */
boolean goldenTime, boolean goldenTime,
/** SHORTS | CLIP | FULL | UNKNOWN */ /** SHORTS | CLIP | FULL | UNKNOWN */
@ -51,6 +53,7 @@ public record FeedItemDto(
v.isBookmarked(), v.isBookmarked(),
v.getSource(), v.getSource(),
v.getMatchedPerson(), v.getMatchedPerson(),
v.getEmbeddable(),
FeedBadges.isGoldenTime(v.getPublishedAt(), now), FeedBadges.isGoldenTime(v.getPublishedAt(), now),
FeedBadges.lengthBucket(v.getDurationSec()), FeedBadges.lengthBucket(v.getDurationSec()),
FeedBadges.isRising(v.getViewsPerHour()), FeedBadges.isRising(v.getViewsPerHour()),

View File

@ -169,6 +169,21 @@
</div> </div>
</div> </div>
<!-- 영상 미리보기 -->
<div id="videoModal" class="modal-overlay" onclick="if(event.target===this) closeVideo()">
<div class="modal-card modal-wide" role="dialog" aria-modal="true" aria-labelledby="vmTitle">
<div class="modal-head">
<h3 id="vmTitle" class="truncate" style="padding-right:0.5rem;">영상</h3>
<button class="modal-close" id="vmClose" onclick="closeVideo()" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div id="vmFrame" class="vm-frame"></div>
<p class="text-xs text-muted mt-2" id="vmMeta"></p>
<div class="vm-actions" id="vmActions"></div>
</div>
</div>
</div>
<!-- 토스트 --> <!-- 토스트 -->
<div id="toast" class="toast" role="status" aria-live="polite"></div> <div id="toast" class="toast" role="status" aria-live="polite"></div>
@ -201,7 +216,11 @@
.fcard.is-worked { opacity:.55; } .fcard.is-worked { opacity:.55; }
.fcard.is-worked:hover { opacity:1; } .fcard.is-worked:hover { opacity:1; }
.fthumb { position:relative; display:block; background:var(--inset); aspect-ratio:16/9; } /* 썸네일·제목은 버튼이지만 링크처럼 보여야 하므로 기본 버튼 스타일을 지운다 */
.fthumb {
position:relative; display:block; background:var(--inset); aspect-ratio:16/9;
width:100%; padding:0; border:0; cursor:pointer;
}
.fthumb img { width:100%; height:100%; object-fit:cover; display:block; } .fthumb img { width:100%; height:100%; object-fit:cover; display:block; }
.fthumb:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } .fthumb:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
.fdur { .fdur {
@ -217,6 +236,14 @@
font-size:0.68rem; font-weight:700; padding:2px 6px; border-radius:4px; line-height:1.5; font-size:0.68rem; font-weight:700; padding:2px 6px; border-radius:4px; line-height:1.5;
} }
.fgolden svg { width:11px; height:11px; } .fgolden svg { width:11px; height:11px; }
/* 외부 재생 표시 — 눌렀을 때 YouTube 로 나간다는 예고 */
.fext {
position:absolute; right:6px; top:6px;
display:inline-flex; align-items:center; justify-content:center;
width:22px; height:22px; border-radius:5px;
background:rgba(0,0,0,.72); color:#fff;
}
.fext svg { width:12px; height:12px; }
.fbody { padding:0.7rem 0.8rem 0.55rem; display:flex; flex-direction:column; gap:0.35rem; flex:1; } .fbody { padding:0.7rem 0.8rem 0.55rem; display:flex; flex-direction:column; gap:0.35rem; flex:1; }
.fmeta { display:flex; align-items:center; gap:0.35rem; font-size:0.72rem; color:var(--text-3); min-width:0; } .fmeta { display:flex; align-items:center; gap:0.35rem; font-size:0.72rem; color:var(--text-3); min-width:0; }
@ -224,8 +251,11 @@
.ftitle { .ftitle {
font-size:0.87rem; font-weight:600; line-height:1.4; color:var(--text); font-size:0.87rem; font-weight:600; line-height:1.4; color:var(--text);
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
width:100%; padding:0; border:0; background:none; text-align:left;
font-family:inherit; cursor:pointer;
} }
.ftitle:hover { text-decoration:underline; } .ftitle:hover { text-decoration:underline; }
.ftitle:focus-visible { outline:2px solid var(--accent); outline-offset:2px; border-radius:2px; }
.fstats { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; font-size:0.72rem; color:var(--text-3); } .fstats { display:flex; align-items:center; gap:0.4rem; flex-wrap:wrap; font-size:0.72rem; color:var(--text-3); }
.fstats .num { font-family:var(--font-mono); font-variant-numeric:tabular-nums; } .fstats .num { font-family:var(--font-mono); font-variant-numeric:tabular-nums; }
@ -299,6 +329,18 @@
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
} }
/* ===== 영상 미리보기 ===== */
.modal-wide { max-width:900px; }
.vm-frame {
width:100%; aspect-ratio:16/9; background:#000;
border-radius:var(--r-sm); overflow:hidden;
}
/* 쇼츠는 세로라 화면을 다 먹지 않게 높이를 묶는다 */
.vm-frame.portrait { aspect-ratio:9/16; max-height:70vh; width:auto; margin:0 auto; }
.vm-frame iframe { width:100%; height:100%; border:0; display:block; }
.vm-actions { display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.8rem; }
.vm-actions .btn { min-height:40px; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.fcard, .feed-tab, .toast, .factions .icon-btn, .seed-row button { transition:none; } .fcard, .feed-tab, .toast, .factions .icon-btn, .seed-row button { transition:none; }
} }
@ -542,20 +584,28 @@
onclick="sendToQueue(${it.id})"> onclick="sendToQueue(${it.id})">
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`); <i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
// 임베드 차단 영상은 눌렀을 때 YouTube 로 나가므로 미리 알려준다
const blocked = it.embeddable === false;
const playHint = blocked
? `<span class="fext" title="이 채널은 외부 재생을 막아둬서 YouTube 로 열립니다">
<i data-lucide="external-link"></i></span>`
: '';
return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}"> return `<article class="fcard${it.worked ? ' is-worked' : ''}" data-id="${it.id}">
<a class="fthumb" href="${url}" target="_blank" rel="noopener" <button class="fthumb" type="button" onclick="openVideo(${it.id})"
aria-label="${esc(it.title)} — YouTube 에서 열기"> aria-label="${esc(it.title)} — ${blocked ? 'YouTube 에서 열기' : '여기서 재생'}">
<img src="${esc(it.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270"> <img src="${esc(it.thumbnailUrl)}" alt="" loading="lazy" width="480" height="270">
${golden} ${golden}
${playHint}
${dur ? `<span class="fdur">${dur}</span>` : ''} ${dur ? `<span class="fdur">${dur}</span>` : ''}
</a> </button>
<div class="fbody"> <div class="fbody">
<div class="fmeta"> <div class="fmeta">
<span class="fprog">${esc(it.channelTitle || '-')}</span> <span class="fprog">${esc(it.channelTitle || '-')}</span>
<span>·</span> <span>·</span>
<span>${fmtAgo(it.publishedAt)}</span> <span>${fmtAgo(it.publishedAt)}</span>
</div> </div>
<a class="ftitle" href="${url}" target="_blank" rel="noopener">${esc(it.title)}</a> <button class="ftitle" type="button" onclick="openVideo(${it.id})">${esc(it.title)}</button>
<div class="fstats"> <div class="fstats">
<span class="num">${fmtNum(it.viewCount)}회</span>${vph} <span class="num">${fmtNum(it.viewCount)}회</span>${vph}
${badgeHtml(it)} ${badgeHtml(it)}
@ -606,6 +656,61 @@
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
} }
// ---------- 영상 미리보기 ----------
let lastFocused = null;
function openVideo(id){
const it = items.find(v => v.id === id);
if(!it) return;
const url = 'https://www.youtube.com/watch?v=' + encodeURIComponent(it.videoId);
// 임베드를 막아둔 채널이 많다(실측 9건 중 4건). 깨진 플레이어를 보여주느니
// 바로 YouTube 로 보낸다. embeddable 이 null 이면 아직 모르니 일단 시도한다.
if(it.embeddable === false){
window.open(url, '_blank', 'noopener');
return;
}
lastFocused = document.activeElement;
document.getElementById('vmTitle').textContent = it.title;
// 쇼츠는 세로, 나머지는 가로
const frame = document.getElementById('vmFrame');
frame.classList.toggle('portrait', it.lengthBucket === 'SHORTS');
frame.innerHTML = `<iframe src="https://www.youtube.com/embed/${encodeURIComponent(it.videoId)}?autoplay=1&rel=0"
title="${esc(it.title)}" allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
allowfullscreen></iframe>`;
const dur = fmtDur(it.durationSec);
document.getElementById('vmMeta').textContent =
`${it.channelTitle || '-'} · ${fmtAgo(it.publishedAt)} · ${fmtNum(it.viewCount)}회${dur ? ' · ' + dur : ''}`;
// 임베드가 막힌 영상이 있어 YouTube 로 여는 길을 항상 열어둔다
const inQueue = queued.has(it.videoId);
const queueBtn = currentTab === 'RIVAL' ? '' : (inQueue
? `<a class="btn btn-secondary" href="/shortform">
<i data-lucide="check" style="width:14px;"></i> 큐에 있음</a>`
: `<button class="btn btn-primary" onclick="sendToQueue(${it.id}); closeVideo();">
<i data-lucide="clapperboard" style="width:14px;"></i> 숏폼 큐로</button>`);
document.getElementById('vmActions').innerHTML = `
${queueBtn}
<a class="btn btn-secondary" href="${url}" target="_blank" rel="noopener">
<i data-lucide="external-link" style="width:14px;"></i> YouTube 에서 열기</a>`;
document.getElementById('videoModal').classList.add('open');
if(window.lucide) lucide.createIcons();
document.getElementById('vmClose').focus();
}
function closeVideo(){
const modal = document.getElementById('videoModal');
if(!modal.classList.contains('open')) return;
modal.classList.remove('open');
document.getElementById('vmFrame').innerHTML = ''; // 재생 중지
if(lastFocused && lastFocused.focus) lastFocused.focus();
lastFocused = null;
}
// ---------- 숏폼 큐 ---------- // ---------- 숏폼 큐 ----------
/** 큐에 이미 담긴 videoId 를 받아둔다. 실패해도 피드 자체는 보여야 하므로 조용히 넘긴다. */ /** 큐에 이미 담긴 videoId 를 받아둔다. 실패해도 피드 자체는 보여야 하므로 조용히 넘긴다. */
async function loadQueued(){ async function loadQueued(){
@ -907,7 +1012,7 @@
} }
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if(e.key === 'Escape'){ closeSeeds(); closePersons(); } if(e.key === 'Escape'){ closeVideo(); closeSeeds(); closePersons(); }
}); });
// ---------- init ---------- // ---------- init ----------