썸네일·제목을 누르면 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>
689 lines
35 KiB
Java
689 lines
35 KiB
Java
package com.hlab.yanalyst.domain.channel;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.hlab.yanalyst.domain.production.dto.ScriptResponseDto;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.core.io.ByteArrayResource;
|
|
import org.springframework.core.io.FileSystemResource;
|
|
import org.springframework.core.io.Resource;
|
|
import org.springframework.http.HttpEntity;
|
|
import org.springframework.http.HttpHeaders;
|
|
import org.springframework.http.MediaType;
|
|
import org.springframework.http.ResponseEntity;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.util.LinkedMultiValueMap;
|
|
import org.springframework.util.MultiValueMap;
|
|
import org.springframework.web.client.RestTemplate;
|
|
import org.springframework.web.multipart.MultipartFile;
|
|
import org.springframework.web.util.UriComponentsBuilder;
|
|
|
|
import java.io.File;
|
|
import java.time.LocalDateTime;
|
|
import java.time.format.DateTimeFormatter;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
|
|
@Slf4j
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Transactional(readOnly = true)
|
|
public class ChannelService {
|
|
|
|
private final ChannelRepository channelRepository;
|
|
private final RestTemplate restTemplate;
|
|
private final RestTemplate pythonRestTemplate; // 전사/렌더 등 장시간 호출용(긴 read timeout). 빈 이름으로 구분 주입.
|
|
private final ObjectMapper objectMapper;
|
|
|
|
private final ChannelVideoRepository channelVideoRepository;
|
|
private final ChannelVideoScriptRepository channelVideoScriptRepository;
|
|
private final ChannelSnapshotRepository channelSnapshotRepository;
|
|
private final RecommendedChannelRepository recommendedChannelRepository;
|
|
|
|
@Value("${youtube.api.key}") // application.yml(youtube.api.key) → 환경변수 YOUTUBE_API_KEY 오버라이드
|
|
private String youtubeApiKey;
|
|
|
|
@Value("${python.base-url:http://h-python.tolag.shop}") // Python 마이크로서비스(자막/전사) 베이스 URL
|
|
private String pythonBaseUrl;
|
|
|
|
@Transactional
|
|
public Channel saveChannelFromUrl(String url) {
|
|
String identifier = extractIdentifier(url);
|
|
boolean isHandle = url.contains("@");
|
|
|
|
String apiUrl = "https://www.googleapis.com/youtube/v3/channels";
|
|
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(apiUrl)
|
|
.queryParam("part", "snippet,statistics,contentDetails")
|
|
.queryParam("key", youtubeApiKey);
|
|
|
|
if (isHandle) {
|
|
builder.queryParam("forHandle", identifier);
|
|
} else {
|
|
builder.queryParam("id", identifier);
|
|
}
|
|
|
|
try {
|
|
// 한글 @핸들이 들어올 수 있으므로 URI 오버로드로 넘긴다.
|
|
// toUriString() 을 넘기면 RestTemplate 이 URI 템플릿으로 보고 이중 인코딩한다.
|
|
JsonNode root = restTemplate.getForObject(builder.build().encode().toUri(), JsonNode.class);
|
|
JsonNode items = root.path("items");
|
|
if (items.isEmpty()) {
|
|
throw new IllegalArgumentException("Channel not found for identifier: " + identifier);
|
|
}
|
|
|
|
JsonNode item = items.get(0);
|
|
String channelId = item.get("id").asText();
|
|
JsonNode snippet = item.get("snippet");
|
|
JsonNode statistics = item.get("statistics");
|
|
JsonNode contentDetails = item.get("contentDetails");
|
|
|
|
String title = snippet.get("title").asText();
|
|
String description = snippet.get("description").asText();
|
|
String thumbnailUrl = snippet.get("thumbnails").get("high").get("url").asText();
|
|
String publishedAtStr = snippet.get("publishedAt").asText(); // ISO 8601
|
|
LocalDateTime publishedAt = LocalDateTime.parse(publishedAtStr, DateTimeFormatter.ISO_DATE_TIME);
|
|
|
|
Long viewCount = Long.parseLong(statistics.get("viewCount").asText());
|
|
Long subscriberCount = Long.parseLong(statistics.get("subscriberCount").asText());
|
|
Long videoCount = Long.parseLong(statistics.get("videoCount").asText());
|
|
|
|
String uploadsPlaylistId = contentDetails.path("relatedPlaylists").path("uploads").asText();
|
|
String country = snippet.path("country").asText(null); // ISO 국가코드(예: KR/JP/US), 미설정 채널은 null
|
|
|
|
Channel channel = channelRepository.findByChannelId(channelId)
|
|
.map(existingChannel -> {
|
|
existingChannel.update(title, description, thumbnailUrl, subscriberCount, viewCount, videoCount);
|
|
existingChannel.setUploadsPlaylistId(uploadsPlaylistId);
|
|
return existingChannel;
|
|
})
|
|
.orElseGet(() -> Channel.builder()
|
|
.channelId(channelId)
|
|
.title(title)
|
|
.description(description)
|
|
.thumbnailUrl(thumbnailUrl)
|
|
.subscriberCount(subscriberCount)
|
|
.viewCount(viewCount)
|
|
.videoCount(videoCount)
|
|
.publishedAt(publishedAt)
|
|
.uploadsPlaylistId(uploadsPlaylistId)
|
|
.build());
|
|
|
|
// Reflected updates for new field if setters are not available in update method yet
|
|
// Assuming setter or reflection, but we added uploadsPlaylistId field.
|
|
// Better to update entity directly if update method doesn't cover it.
|
|
// Since we didn't add uploadsPlaylistId to update() method on Channel entity yet, we might miss it on update.
|
|
// However, we can use reflection or add a method. For now let's rely on JPA saving the new field if it's new.
|
|
// Wait, for existing entity, we need to set it.
|
|
// Let's assume we can modify the entity logic or just set it via field access if public/setter.
|
|
// Actually, we should've added it to update method.
|
|
// Let's use a direct field set via reflection or just ignore if it's not critical for now, BUT it IS critical.
|
|
// I will forcefully set it via a new method or assume I can add a setter in next step if needed.
|
|
// Ah, I missed adding it to update(). I will use a custom repository method or simple save.
|
|
// Actually, I can just modify the update logic here slightly if I had setters.
|
|
// Since Channel is @Getter and no Setters (except update method), I should have updated the update method.
|
|
// I will fix Channel.java's update method later or adding a setter.
|
|
// For now, let's proceed and I'll add a 'setUploadsPlaylistId' to Channel entity in a separate tool call if needed or just use what I have.
|
|
// Wait, looking at Channel.java, it has NO SETTERS.
|
|
// I MUST update Channel.java to have a method to set this ID, OR update existing `update` method.
|
|
// I will do that in a separate step. For now, let's persist.
|
|
|
|
// To make sure it saves, I'll invoke a direct SQL update or just rely on 'save' for new ones.
|
|
// For existing, it won't be updated. This is a BUG in my plan.
|
|
// Corrective action: I'll add a setter for uploadsPlaylistId in Channel.java FIRST.
|
|
|
|
// ... (rest of logic)
|
|
// But wait, I can't break the build.
|
|
// Let's implement the rest of the service methods.
|
|
|
|
if (country != null && !country.isBlank()) {
|
|
channel.setCountry(country.toUpperCase());
|
|
}
|
|
Channel saved = channelRepository.save(channel);
|
|
captureSnapshot(saved); // 성장 추이용 일별 스냅샷 기록(upsert)
|
|
return saved;
|
|
|
|
} catch (Exception e) {
|
|
log.error("Failed to fetch channel info for URL: {}", url, e);
|
|
throw new RuntimeException("Failed to fetch channel info", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* country 가 비어있는 기존 채널을, 추천 채널 테이블의 발견 지역(region: KR/JP/US)으로 채운다.
|
|
* (추천에서 등록한 채널은 검색 지역이 곧 국가이므로 신뢰도가 높다. 매칭 없으면 그대로 둔다.)
|
|
* @return 백필된 채널 수
|
|
*/
|
|
@Transactional
|
|
public int backfillCountriesFromRecommend() {
|
|
int updated = 0;
|
|
for (Channel ch : channelRepository.findAll()) {
|
|
if (ch.getCountry() != null && !ch.getCountry().isBlank()) continue;
|
|
String region = recommendedChannelRepository.findByChannelId(ch.getChannelId())
|
|
.map(RecommendedChannel::getRegion).orElse(null);
|
|
if (region != null && !region.isBlank()) {
|
|
ch.setCountry(region.toUpperCase());
|
|
updated++;
|
|
}
|
|
}
|
|
log.info("국가 백필 완료: {}개 채널 갱신", updated);
|
|
return updated;
|
|
}
|
|
|
|
/** 채널 통계 스냅샷을 오늘 날짜로 upsert. */
|
|
private void captureSnapshot(Channel channel) {
|
|
java.time.LocalDate today = java.time.LocalDate.now();
|
|
channelSnapshotRepository.findByChannelIdAndSnapshotDate(channel.getId(), today)
|
|
.ifPresentOrElse(
|
|
s -> s.update(channel.getSubscriberCount(), channel.getViewCount(), channel.getVideoCount()),
|
|
() -> channelSnapshotRepository.save(new ChannelSnapshot(
|
|
channel.getId(), today,
|
|
channel.getSubscriberCount(), channel.getViewCount(), channel.getVideoCount())));
|
|
}
|
|
|
|
/** 채널 통계를 YouTube 에서 다시 받아와 갱신하고 스냅샷을 기록한다. */
|
|
@Transactional
|
|
public Channel refreshChannelStats(Long channelId) {
|
|
Channel c = getChannel(channelId);
|
|
return saveChannelFromUrl("https://www.youtube.com/channel/" + c.getChannelId());
|
|
}
|
|
|
|
/** 채널 성장 추이(일별 스냅샷, 오래된 순). */
|
|
public List<ChannelSnapshot> getGrowth(Long channelId) {
|
|
return channelSnapshotRepository.findByChannelIdOrderBySnapshotDateAsc(channelId);
|
|
}
|
|
|
|
private String extractIdentifier(String url) {
|
|
if (url.contains("youtube.com/")) {
|
|
if (url.contains("@")) {
|
|
String handle = url.substring(url.indexOf("@"));
|
|
try {
|
|
return java.net.URLDecoder.decode(handle, java.nio.charset.StandardCharsets.UTF_8);
|
|
} catch (Exception e) {
|
|
return handle;
|
|
}
|
|
} else if (url.contains("/channel/")) {
|
|
String[] parts = url.split("/channel/");
|
|
if (parts.length > 1) {
|
|
return parts[1].split("/")[0].split("\\?")[0];
|
|
}
|
|
}
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/** 내 채널 목록. 피드 시드(SOURCE/RIVAL)는 /feed 에서 따로 관리하므로 제외한다. */
|
|
public List<Channel> getAllChannels() {
|
|
return channelRepository.findMyChannels();
|
|
}
|
|
|
|
public Channel getChannel(Long id) {
|
|
return channelRepository.findById(id)
|
|
.orElseThrow(() -> new IllegalArgumentException("Channel not found with id: " + id));
|
|
}
|
|
|
|
@Transactional
|
|
public void deleteChannel(Long id) {
|
|
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(id);
|
|
for (ChannelVideo video : videos) {
|
|
channelVideoScriptRepository.deleteAll(
|
|
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
|
channelVideoRepository.delete(video);
|
|
}
|
|
channelRepository.deleteById(id);
|
|
}
|
|
|
|
@Transactional
|
|
public void collectChannelVideos(Long channelId) {
|
|
Channel channel = getChannel(channelId);
|
|
String uploadPlaylistId = channel.getUploadsPlaylistId();
|
|
|
|
if (uploadPlaylistId == null || uploadPlaylistId.isEmpty()) {
|
|
// Self-healing: try to update channel info
|
|
try {
|
|
String tempUrl = "https://www.youtube.com/channel/" + channel.getChannelId();
|
|
Channel updatedChannel = saveChannelFromUrl(tempUrl);
|
|
uploadPlaylistId = updatedChannel.getUploadsPlaylistId();
|
|
channel = updatedChannel;
|
|
} catch (Exception e) {
|
|
log.error("Failed to auto-update channel info during sync", e);
|
|
throw new IllegalArgumentException("Uploads playlist ID not found. Please re-add/update the channel.");
|
|
}
|
|
}
|
|
|
|
String nextPageToken = null;
|
|
int maxVideos = 200; // Safety limit
|
|
int currentCount = 0;
|
|
|
|
do {
|
|
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/playlistItems")
|
|
.queryParam("part", "snippet,contentDetails")
|
|
.queryParam("playlistId", uploadPlaylistId)
|
|
.queryParam("maxResults", 50)
|
|
.queryParam("key", youtubeApiKey)
|
|
.queryParamIfPresent("pageToken", java.util.Optional.ofNullable(nextPageToken))
|
|
.toUriString();
|
|
|
|
try {
|
|
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
|
JsonNode items = root.path("items");
|
|
nextPageToken = root.path("nextPageToken").asText(null);
|
|
|
|
List<String> videoIds = new java.util.ArrayList<>();
|
|
for (JsonNode item : items) {
|
|
String videoId = item.get("snippet").get("resourceId").get("videoId").asText();
|
|
videoIds.add(videoId);
|
|
}
|
|
|
|
if (!videoIds.isEmpty()) {
|
|
processVideos(channel, videoIds);
|
|
currentCount += videoIds.size();
|
|
}
|
|
|
|
} catch (Exception e) {
|
|
log.error("Error fetching playlist items", e);
|
|
break;
|
|
}
|
|
|
|
} while (nextPageToken != null && currentCount < maxVideos);
|
|
}
|
|
|
|
private void processVideos(Channel channel, List<String> videoIds) {
|
|
// 내 채널(OWN)의 영상은 소재가 아니라 성과라 출처를 달리해 수집함/발굴에서 격리한다.
|
|
upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null);
|
|
}
|
|
|
|
/**
|
|
* videos.list 로 상세를 받아 ChannelVideo 를 upsert 한다.
|
|
*
|
|
* @param source 저장할 출처. CHANNEL(등록 채널) / SOURCE(소재 원본) / RIVAL(경쟁 쇼츠)
|
|
* @param shortsOnly null 이면 전체, TRUE 면 Shorts 만, FALSE 면 롱폼만 저장
|
|
* @param publishedAfter 이 시각 이전 업로드는 건너뜀(null 이면 제한 없음)
|
|
* @return 저장·갱신한 영상 수
|
|
*/
|
|
private int upsertVideos(Channel channel, List<String> videoIds, String source,
|
|
Boolean shortsOnly, LocalDateTime publishedAfter) {
|
|
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/videos")
|
|
// status 는 임베드 가능 여부(embeddable) 때문에 필요하다. part 를 늘려도 쿼터는 그대로다.
|
|
.queryParam("part", "snippet,statistics,contentDetails,status")
|
|
.queryParam("id", String.join(",", videoIds))
|
|
.queryParam("key", youtubeApiKey)
|
|
.toUriString();
|
|
|
|
int saved = 0;
|
|
try {
|
|
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
|
JsonNode items = root.path("items");
|
|
|
|
for (JsonNode item : items) {
|
|
String videoId = item.get("id").asText();
|
|
JsonNode snippet = item.get("snippet");
|
|
JsonNode statistics = item.get("statistics");
|
|
JsonNode contentDetails = item.get("contentDetails");
|
|
|
|
String title = snippet.get("title").asText();
|
|
String thumbnailUrl = snippet.get("thumbnails").has("maxres")
|
|
? snippet.get("thumbnails").get("maxres").get("url").asText()
|
|
: snippet.get("thumbnails").get("high").get("url").asText();
|
|
|
|
LocalDateTime publishedAt = LocalDateTime.parse(snippet.get("publishedAt").asText(), DateTimeFormatter.ISO_DATE_TIME);
|
|
|
|
Long viewCount = statistics.has("viewCount") ? Long.parseLong(statistics.get("viewCount").asText()) : 0L;
|
|
Long likeCount = statistics.has("likeCount") ? Long.parseLong(statistics.get("likeCount").asText()) : 0L;
|
|
String duration = contentDetails.get("duration").asText();
|
|
|
|
// --- 파생 분석 지표 계산 ---
|
|
Integer durationSec = VideoMetrics.parseDurationSec(duration);
|
|
Boolean isShorts = VideoMetrics.isShorts(durationSec);
|
|
|
|
// 포맷/기간 필터 — 피드 수집에서만 사용(일반 채널 수집은 둘 다 null)
|
|
if (shortsOnly != null && shortsOnly != isShorts) continue;
|
|
if (publishedAfter != null && publishedAt.isBefore(publishedAfter)) continue;
|
|
|
|
java.math.BigDecimal viewsPerHour = VideoMetrics.viewsPerHour(viewCount, publishedAt);
|
|
java.math.BigDecimal viewsPerSubRatio = VideoMetrics.viewsPerSubRatio(viewCount, channel.getSubscriberCount());
|
|
String ytChannelId = channel.getChannelId();
|
|
String channelTitle = channel.getTitle();
|
|
Long subscriberCount = channel.getSubscriberCount();
|
|
// 해시태그는 시드 역분석(내 채널 → 소재 원본 채널 후보)의 재료가 된다.
|
|
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 -> {
|
|
v.update(title, thumbnailUrl, viewCount, likeCount);
|
|
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()
|
|
.channel(channel)
|
|
.videoId(videoId)
|
|
.title(title)
|
|
.thumbnailUrl(thumbnailUrl)
|
|
.publishedAt(publishedAt)
|
|
.viewCount(viewCount)
|
|
.likeCount(likeCount)
|
|
.duration(duration)
|
|
.build();
|
|
newVideo.applyMetrics(durationSec, isShorts, viewsPerHour);
|
|
newVideo.applyFeedInfo(ytChannelId, channelTitle, subscriberCount, viewsPerSubRatio, source);
|
|
newVideo.applyHashtags(hashtags);
|
|
newVideo.applyEmbeddable(embeddable);
|
|
channelVideoRepository.save(newVideo);
|
|
});
|
|
saved++;
|
|
}
|
|
} catch (Exception e) {
|
|
log.error("Error fetching video details", e);
|
|
}
|
|
return saved;
|
|
}
|
|
|
|
/**
|
|
* 피드 시드 채널의 최근 업로드를 1페이지(최대 50건)만 수집한다.
|
|
* 소스 채널은 롱폼만, 경쟁 채널은 쇼츠만 저장한다.
|
|
*
|
|
* @param channel SOURCE 또는 RIVAL 역할의 채널
|
|
* @param publishedAfter 이 시각 이후 업로드만 수집
|
|
* @return 저장·갱신된 영상 수
|
|
* @throws IllegalStateException uploads 플레이리스트를 찾을 수 없을 때
|
|
*/
|
|
@Transactional
|
|
public int collectFeedVideos(Channel channel, LocalDateTime publishedAfter) {
|
|
String role = channel.roleOrDefault();
|
|
String uploadsPlaylistId = channel.getUploadsPlaylistId();
|
|
if (uploadsPlaylistId == null || uploadsPlaylistId.isBlank()) {
|
|
throw new IllegalStateException("uploads 플레이리스트가 없습니다: " + channel.getChannelId());
|
|
}
|
|
|
|
String apiUrl = UriComponentsBuilder.fromHttpUrl("https://www.googleapis.com/youtube/v3/playlistItems")
|
|
.queryParam("part", "contentDetails")
|
|
.queryParam("playlistId", uploadsPlaylistId)
|
|
.queryParam("maxResults", 50)
|
|
.queryParam("key", youtubeApiKey)
|
|
.toUriString();
|
|
|
|
JsonNode root = restTemplate.getForObject(apiUrl, JsonNode.class);
|
|
if (root == null) throw new IllegalStateException("playlistItems 응답이 비어있습니다: " + channel.getChannelId());
|
|
|
|
List<String> videoIds = new ArrayList<>();
|
|
for (JsonNode item : root.path("items")) {
|
|
String videoId = item.path("contentDetails").path("videoId").asText(null);
|
|
if (videoId != null && !videoId.isBlank()) videoIds.add(videoId);
|
|
}
|
|
if (videoIds.isEmpty()) return 0;
|
|
|
|
// SOURCE = 롱폼만(공식계정 쇼츠는 이미 잘린 결과물), RIVAL = 쇼츠만
|
|
Boolean shortsOnly = ChannelRole.acceptsShorts(role);
|
|
String source = ChannelRole.RIVAL.equals(role) ? ChannelRole.RIVAL : ChannelRole.SOURCE;
|
|
return upsertVideos(channel, videoIds, source, shortsOnly, publishedAfter);
|
|
}
|
|
|
|
|
|
|
|
public List<ChannelVideo> getChannelVideos(Long channelId) {
|
|
return channelVideoRepository.findByChannelId(channelId);
|
|
}
|
|
|
|
public List<ChannelVideo> getChannelsVideos(List<Long> channelIds) {
|
|
return channelVideoRepository.findByChannelIdInOrderByPublishedAtDesc(channelIds);
|
|
}
|
|
|
|
public List<Channel> getChannelsByIds(List<Long> ids) {
|
|
return channelRepository.findAllById(ids);
|
|
}
|
|
|
|
@Transactional
|
|
public void extractScript(Long channelVideoId) {
|
|
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
|
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
|
|
|
String apiUrl = pythonBaseUrl + "/transcript";
|
|
// Construct standard YouTube URL from video ID
|
|
String videoUrl = "https://www.youtube.com/watch?v=" + video.getVideoId();
|
|
|
|
java.util.Map<String, String> requestBody = java.util.Collections.singletonMap("url", videoUrl);
|
|
|
|
log.info("Requesting transcript for URL: {}", videoUrl);
|
|
|
|
try {
|
|
org.springframework.http.ResponseEntity<String> response = restTemplate.postForEntity(apiUrl, requestBody, String.class);
|
|
|
|
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
|
com.hlab.yanalyst.domain.production.dto.ScriptResponseDto scriptDto =
|
|
objectMapper.readValue(response.getBody(), com.hlab.yanalyst.domain.production.dto.ScriptResponseDto.class);
|
|
|
|
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
|
|
channelVideoScriptRepository.deleteAll(
|
|
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
|
|
|
ChannelVideoScript script = new ChannelVideoScript();
|
|
script.setChannelVideoId(channelVideoId);
|
|
script.setVideoId(video.getVideoId());
|
|
script.setLanguage(scriptDto.getLanguage());
|
|
script.setTranscript(scriptDto.getTranscript());
|
|
|
|
channelVideoScriptRepository.save(script);
|
|
|
|
video.setHasScript(true);
|
|
channelVideoRepository.save(video);
|
|
|
|
log.info("Saved script for channel video id: {}", channelVideoId);
|
|
|
|
} else {
|
|
log.error("Failed to fetch script. Status: {}", response.getStatusCode());
|
|
throw new RuntimeException("External API failed with status: " + response.getStatusCode());
|
|
}
|
|
} catch (Exception e) {
|
|
log.error("Error extracting script", e);
|
|
throw new RuntimeException("Error extracting script", e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 업로드한 영상 파일을 Python /transcribe(faster-whisper)로 보내 영상 싱크 세그먼트를 추출·저장한다.
|
|
* 평문 transcript 와 segments_json 을 함께 저장하고 hasScript=true 로 승격한다.
|
|
*
|
|
* @return 추출 결과 DTO(segments/transcript/language/duration)
|
|
*/
|
|
@Transactional
|
|
public ScriptResponseDto transcribeFromFile(Long channelVideoId, MultipartFile file, String language) {
|
|
try {
|
|
return doTranscribe(channelVideoId, toFileResource(file), file.getSize(), language);
|
|
} catch (java.io.IOException e) {
|
|
throw new RuntimeException("업로드 파일을 읽지 못했습니다", e);
|
|
}
|
|
}
|
|
|
|
/** 다운로드 캐시 파일(yt-dlp 결과)을 Whisper 로 전사한다 — 업로드 없이 동일 파이프라인 재사용. */
|
|
@Transactional
|
|
public ScriptResponseDto transcribeFromCached(Long channelVideoId, File file, String language) {
|
|
return doTranscribe(channelVideoId, toFileResource(file), file.length(), language);
|
|
}
|
|
|
|
/** 전사 핵심: 전송할 Resource(업로드 ByteArray / 캐시 File)만 다르고 나머지 로직은 공통. */
|
|
private ScriptResponseDto doTranscribe(Long channelVideoId, Resource resource, long sizeBytes, String language) {
|
|
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
|
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
|
|
|
String apiUrl = pythonBaseUrl + "/transcribe";
|
|
log.info("Requesting whisper transcription for video {} ({} bytes, lang={})",
|
|
channelVideoId, sizeBytes, language);
|
|
|
|
try {
|
|
HttpHeaders headers = new HttpHeaders();
|
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
|
|
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
|
body.add("file", resource);
|
|
if (language != null && !language.isBlank()) {
|
|
body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja 등)
|
|
}
|
|
|
|
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
|
|
ResponseEntity<String> response = pythonRestTemplate.postForEntity(apiUrl, request, String.class);
|
|
|
|
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
|
throw new RuntimeException("Transcribe API failed with status: " + response.getStatusCode());
|
|
}
|
|
|
|
ScriptResponseDto dto = objectMapper.readValue(response.getBody(), ScriptResponseDto.class);
|
|
persistScript(video, channelVideoId, dto);
|
|
|
|
log.info("Saved whisper transcript for channel video id: {} ({} segments)",
|
|
channelVideoId, dto.getSegments() == null ? 0 : dto.getSegments().size());
|
|
return dto;
|
|
} catch (Exception e) {
|
|
log.error("Error transcribing uploaded file for video " + channelVideoId, e);
|
|
throw new RuntimeException("Error transcribing uploaded file", e);
|
|
}
|
|
}
|
|
|
|
/** 전사 결과 저장: 기존 스크립트 제거 후 새 스크립트 저장 + hasScript 승격. */
|
|
private void persistScript(ChannelVideo video, Long channelVideoId, ScriptResponseDto dto)
|
|
throws com.fasterxml.jackson.core.JsonProcessingException {
|
|
// 재추출 시 기존 스크립트(중복 포함)를 먼저 제거해 videoId 당 1건만 유지한다.
|
|
channelVideoScriptRepository.deleteAll(
|
|
channelVideoScriptRepository.findAllByVideoId(video.getVideoId()));
|
|
|
|
ChannelVideoScript script = new ChannelVideoScript();
|
|
script.setChannelVideoId(channelVideoId);
|
|
script.setVideoId(video.getVideoId());
|
|
script.setLanguage(dto.getLanguage());
|
|
script.setTranscript(dto.getTranscript());
|
|
script.setSegmentsJson(objectMapper.writeValueAsString(
|
|
dto.getSegments() == null ? Collections.emptyList() : dto.getSegments()));
|
|
channelVideoScriptRepository.save(script);
|
|
|
|
video.setHasScript(true);
|
|
channelVideoRepository.save(video);
|
|
}
|
|
|
|
/** 최신 스크립트의 세그먼트 목록을 반환한다. 없으면 빈 리스트. */
|
|
public List<ScriptSegment> getSegments(Long channelVideoId) {
|
|
ChannelVideo video = channelVideoRepository.findById(channelVideoId)
|
|
.orElseThrow(() -> new IllegalArgumentException("Video not found: " + channelVideoId));
|
|
return channelVideoScriptRepository.findFirstByVideoIdOrderByIdDesc(video.getVideoId())
|
|
.map(s -> parseSegments(s.getSegmentsJson()))
|
|
.orElseGet(Collections::emptyList);
|
|
}
|
|
|
|
/** 세그먼트 JSON 문자열 → ScriptSegment 목록(파싱 실패/빈 값이면 빈 리스트). */
|
|
public List<ScriptSegment> parseSegments(String segmentsJson) {
|
|
if (segmentsJson == null || segmentsJson.isBlank()) {
|
|
return Collections.emptyList();
|
|
}
|
|
try {
|
|
List<ScriptResponseDto.Segment> raw = objectMapper.readValue(
|
|
segmentsJson,
|
|
objectMapper.getTypeFactory().constructCollectionType(List.class, ScriptResponseDto.Segment.class));
|
|
List<ScriptSegment> result = new ArrayList<>(raw.size());
|
|
for (ScriptResponseDto.Segment s : raw) {
|
|
result.add(new ScriptSegment(s.getStart(), s.getEnd(), s.getText()));
|
|
}
|
|
return result;
|
|
} catch (Exception e) {
|
|
log.warn("Failed to parse segments json", e);
|
|
return Collections.emptyList();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 업로드 영상에서 "말 없는 구간"을 잘라낸(+선택 배속) 영상을 Python /render(ffmpeg)로 만들어 바이트로 반환한다.
|
|
* keep 구간은 저장된 세그먼트로 서버가 계산하므로 미리보기와 정확히 일치한다.
|
|
*/
|
|
public byte[] renderTrimmed(Long channelVideoId, MultipartFile file, double pad, double minGap, double speed) {
|
|
try {
|
|
return doRender(channelVideoId, toFileResource(file), pad, minGap, speed);
|
|
} catch (java.io.IOException e) {
|
|
throw new RuntimeException("업로드 파일을 읽지 못했습니다", e);
|
|
}
|
|
}
|
|
|
|
/** 다운로드 캐시 파일(yt-dlp 결과)로 말 없는 구간 제거(+배속) 렌더 — 업로드 없이 동일 파이프라인 재사용. */
|
|
public byte[] renderTrimmedFromCached(Long channelVideoId, File file, double pad, double minGap, double speed) {
|
|
return doRender(channelVideoId, toFileResource(file), pad, minGap, speed);
|
|
}
|
|
|
|
/** 렌더 핵심: 전송할 Resource(업로드 ByteArray / 캐시 File)만 다르고 나머지 로직은 공통. */
|
|
private byte[] doRender(Long channelVideoId, Resource resource, double pad, double minGap, double speed) {
|
|
List<ScriptSegment> segments = getSegments(channelVideoId);
|
|
if (segments.isEmpty()) {
|
|
// 사용자에게 이유가 보이도록 400(IllegalArgumentException → GlobalExceptionHandler 가 메시지 노출).
|
|
throw new IllegalArgumentException("세그먼트가 없습니다. 먼저 영상 업로드·전사를 실행하세요.");
|
|
}
|
|
KeepIntervalPlanner.Plan plan = KeepIntervalPlanner.plan(segments, pad, minGap);
|
|
|
|
String apiUrl = pythonBaseUrl + "/render";
|
|
try {
|
|
HttpHeaders headers = new HttpHeaders();
|
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
|
|
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
|
body.add("file", resource);
|
|
body.add("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...]
|
|
body.add("speed", String.valueOf(speed));
|
|
|
|
HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(body, headers);
|
|
ResponseEntity<byte[]> response = pythonRestTemplate.postForEntity(apiUrl, request, byte[].class);
|
|
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
|
throw new RuntimeException("Render API failed with status: " + response.getStatusCode());
|
|
}
|
|
return response.getBody();
|
|
} catch (Exception e) {
|
|
log.error("Error rendering trimmed video for " + channelVideoId, e);
|
|
throw new RuntimeException("Error rendering trimmed video", e);
|
|
}
|
|
}
|
|
|
|
/** File(다운로드 캐시) → multipart 전송용 Resource(파일명 보존, 디스크 스트리밍). */
|
|
private Resource toFileResource(File file) {
|
|
return new FileSystemResource(file);
|
|
}
|
|
|
|
/** MultipartFile → multipart 전송용 Resource(파일명 보존). */
|
|
private Resource toFileResource(MultipartFile file) throws java.io.IOException {
|
|
final String filename = (file.getOriginalFilename() != null && !file.getOriginalFilename().isBlank())
|
|
? file.getOriginalFilename() : "upload.mp4";
|
|
return new ByteArrayResource(file.getBytes()) {
|
|
@Override
|
|
public String getFilename() {
|
|
return filename;
|
|
}
|
|
};
|
|
}
|
|
|
|
@Transactional
|
|
public void extractAllScripts(Long channelId) {
|
|
List<ChannelVideo> videos = channelVideoRepository.findByChannelId(channelId);
|
|
|
|
int successCount = 0;
|
|
int failCount = 0;
|
|
|
|
for (ChannelVideo video : videos) {
|
|
if (!video.isHasScript()) {
|
|
try {
|
|
extractScript(video.getId());
|
|
successCount++;
|
|
// Basic rate limiting/pause to avoid overwhelming the external service if needed
|
|
// Thread.sleep(500);
|
|
} catch (Exception e) {
|
|
log.error("Failed to extract script for video: " + video.getVideoId(), e);
|
|
failCount++;
|
|
// Continue to next video even if one fails
|
|
}
|
|
}
|
|
}
|
|
log.info("Bulk extraction completed. Success: {}, Fail: {}", successCount, failCount);
|
|
}
|
|
}
|