diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index 439a406..67dc7ed 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -307,7 +307,8 @@ public class ChannelService { private int upsertVideos(Channel channel, List videoIds, String source, Boolean shortsOnly, LocalDateTime publishedAfter) { 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("key", youtubeApiKey) .toUriString(); @@ -350,6 +351,9 @@ public class ChannelService { // 해시태그는 시드 역분석(내 채널 → 소재 원본 채널 후보)의 재료가 된다. String hashtags = HashtagExtractor.join( 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) .ifPresentOrElse(v -> { @@ -357,6 +361,7 @@ public class ChannelService { v.applyMetrics(durationSec, isShorts, viewsPerHour); v.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source); v.applyHashtags(hashtags); + v.applyEmbeddable(embeddable); channelVideoRepository.save(v); }, () -> { ChannelVideo newVideo = ChannelVideo.builder() @@ -372,6 +377,7 @@ public class ChannelService { newVideo.applyMetrics(durationSec, isShorts, viewsPerHour); newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source); newVideo.applyHashtags(hashtags); + newVideo.applyEmbeddable(embeddable); channelVideoRepository.save(newVideo); }); saved++; diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java index 5458b8c..287c36d 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelVideo.java @@ -82,6 +82,14 @@ public class ChannelVideo { @Column(name = "hashtags", columnDefinition = "TEXT") private String hashtags; + /** + * 다른 사이트에 임베드할 수 있는가(YouTube status.embeddable). + * 방송사·연예 채널은 막아둔 경우가 많아(실측 9건 중 4건) 미리 알아야 헛클릭을 막는다. + * null 이면 아직 모름 — 일단 재생을 시도한다. + */ + @Column(name = "embeddable") + private Boolean embeddable; + /** * 인물 추적으로 걸린 영상이면 그 인물명. 인물 탭은 source 가 아니라 이 값으로 조회하므로, * 소스 채널에서 이미 수집한 영상이 인물 검색에도 걸리면 두 탭 모두에 나타난다. @@ -179,6 +187,11 @@ public class ChannelVideo { this.hashtags = hashtags; } + /** 임베드 가능 여부를 기록한다. */ + public void applyEmbeddable(Boolean embeddable) { + this.embeddable = embeddable; + } + /** Gemini 가 영상을 보고 만든 내용 요약을 저장한다(영상당 1회). */ public void applyContextSummary(String contextSummary) { this.contextSummary = contextSummary; diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java b/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java index fb3e8e7..472ea65 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/PersonCollectionService.java @@ -138,7 +138,8 @@ public class PersonCollectionService { } if (videoIds.isEmpty()) return 0; - List found = fetchDetails(videoIds); + Map embeddable = new LinkedHashMap<>(); + List found = fetchDetails(videoIds, embeddable); List picks = PersonPicks.keep(found, exclude, publishedAfter, minViewsPerHour); int saved = 0; @@ -151,19 +152,24 @@ public class PersonCollectionService { v.update(f.title(), v.getThumbnailUrl(), f.viewCount(), v.getLikeCount()); v.applyMetrics(f.durationSec(), VideoMetrics.isShorts(f.durationSec()), vph); v.applyMatchedPerson(person); + v.applyEmbeddable(embeddable.get(f.videoId())); channelVideoRepository.save(v); - }, () -> channelVideoRepository.save(ChannelVideo.fromPersonSearch( - f.videoId(), f.title(), thumbnailOf(f.videoId()), f.publishedAt(), f.viewCount(), - f.ytChannelId(), f.channelTitle(), f.durationSec(), vph, null, person))); + }, () -> { + ChannelVideo nv = ChannelVideo.fromPersonSearch( + 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++; } return saved; } /** videos.list 로 길이·조회수·업로드일을 채운다(검색 결과에는 길이가 없다). */ - private List fetchDetails(List videoIds) { + private List fetchDetails(List videoIds, Map embeddableOut) { 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("key", youtubeApiKey) .build().encode().toUri(); @@ -180,6 +186,9 @@ public class PersonCollectionService { LocalDateTime publishedAt = LocalDateTime.parse( snippet.path("publishedAt").asText(), DateTimeFormatter.ISO_DATE_TIME); 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( item.path("id").asText(), snippet.path("title").asText(""), snippet.path("channelId").asText(null), snippet.path("channelTitle").asText(""), diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java index 4b73790..14a65c2 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/FeedItemDto.java @@ -26,6 +26,8 @@ public record FeedItemDto( String source, /** 인물 추적으로 걸렸다면 그 인물명. 인물 탭 카드에 배지로 표시한다. */ String matchedPerson, + /** 사이트 안에서 재생 가능한가. null 이면 아직 모름 — 일단 재생을 시도한다. */ + Boolean embeddable, /** 업로드 24시간 이내 — 선점 골든타임. */ boolean goldenTime, /** SHORTS | CLIP | FULL | UNKNOWN */ @@ -51,6 +53,7 @@ public record FeedItemDto( v.isBookmarked(), v.getSource(), v.getMatchedPerson(), + v.getEmbeddable(), FeedBadges.isGoldenTime(v.getPublishedAt(), now), FeedBadges.lengthBucket(v.getDurationSec()), FeedBadges.isRising(v.getViewsPerHour()), diff --git a/src/main/resources/templates/feed.html b/src/main/resources/templates/feed.html index f8d44aa..6d62644 100644 --- a/src/main/resources/templates/feed.html +++ b/src/main/resources/templates/feed.html @@ -169,6 +169,21 @@ + + +
@@ -201,7 +216,11 @@ .fcard.is-worked { opacity:.55; } .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:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } .fdur { @@ -217,6 +236,14 @@ font-size:0.68rem; font-weight:700; padding:2px 6px; border-radius:4px; line-height:1.5; } .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; } .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 { 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; + width:100%; padding:0; border:0; background:none; text-align:left; + font-family:inherit; cursor:pointer; } .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 .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; } + /* ===== 영상 미리보기 ===== */ + .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) { .fcard, .feed-tab, .toast, .factions .icon-btn, .seed-row button { transition:none; } } @@ -542,20 +584,28 @@ onclick="sendToQueue(${it.id})"> 숏폼 큐로`); + // 임베드 차단 영상은 눌렀을 때 YouTube 로 나가므로 미리 알려준다 + const blocked = it.embeddable === false; + const playHint = blocked + ? ` + ` + : ''; + return `
- +
${esc(it.channelTitle || '-')} · ${fmtAgo(it.publishedAt)}
- ${esc(it.title)} +
${fmtNum(it.viewCount)}회${vph} ${badgeHtml(it)} @@ -606,6 +656,61 @@ 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 = ``; + + 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 + ? ` + 큐에 있음` + : ``); + document.getElementById('vmActions').innerHTML = ` + ${queueBtn} + + YouTube 에서 열기`; + + 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 를 받아둔다. 실패해도 피드 자체는 보여야 하므로 조용히 넘긴다. */ async function loadQueued(){ @@ -907,7 +1012,7 @@ } document.addEventListener('keydown', e => { - if(e.key === 'Escape'){ closeSeeds(); closePersons(); } + if(e.key === 'Escape'){ closeVideo(); closeSeeds(); closePersons(); } }); // ---------- init ----------