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 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 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 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 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 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 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 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 getChannelVideos(Long channelId) { return channelVideoRepository.findByChannelId(channelId); } public List getChannelsVideos(List channelIds) { return channelVideoRepository.findByChannelIdInOrderByPublishedAtDesc(channelIds); } public List getChannelsByIds(List 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 requestBody = java.util.Collections.singletonMap("url", videoUrl); log.info("Requesting transcript for URL: {}", videoUrl); try { org.springframework.http.ResponseEntity 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 body = new LinkedMultiValueMap<>(); body.add("file", resource); if (language != null && !language.isBlank()) { body.add("language", language.trim()); // 자동감지 오류 보정용(ko/en/zh/ja 등) } HttpEntity> request = new HttpEntity<>(body, headers); ResponseEntity 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 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 parseSegments(String segmentsJson) { if (segmentsJson == null || segmentsJson.isBlank()) { return Collections.emptyList(); } try { List raw = objectMapper.readValue( segmentsJson, objectMapper.getTypeFactory().constructCollectionType(List.class, ScriptResponseDto.Segment.class)); List 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 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 body = new LinkedMultiValueMap<>(); body.add("file", resource); body.add("keep", objectMapper.writeValueAsString(plan.keep())); // [{"start":..,"end":..},...] body.add("speed", String.valueOf(speed)); HttpEntity> request = new HttpEntity<>(body, headers); ResponseEntity 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 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); } }