h-lab/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java
hehihoho3@gmail.com 13dd61d80c 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>
2026-08-04 10:16:52 +09:00

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);
}
}