package com.hlab.yanalyst.domain.channel; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.hlab.yanalyst.domain.channel.dto.YoutubeChannelAnalyticsDto; import com.hlab.yanalyst.domain.upload.YoutubeConnection; import com.hlab.yanalyst.domain.upload.YoutubeConnectionRepository; import com.hlab.yanalyst.domain.upload.YoutubeOAuthService; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** YouTube Analytics API와 Data API를 조합해 내 채널 분석용 보고서를 만든다. */ @Service public class YoutubeChannelAnalyticsService { private static final String ANALYTICS_URL = "https://youtubeanalytics.googleapis.com/v2/reports"; private static final String CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels"; private static final String PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"; private static final String VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"; private static final String METRICS = "views,estimatedMinutesWatched,averageViewDuration,subscribersGained,subscribersLost,likes,comments"; private static final ZoneId KOREA = ZoneId.of("Asia/Seoul"); private static final int MAX_RANGE_DAYS = 730; private static final int MAX_UPLOADS = 500; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private final YoutubeOAuthService oauthService; private final YoutubeConnectionRepository connectionRepository; public YoutubeChannelAnalyticsService(@Qualifier("restTemplate") RestTemplate restTemplate, ObjectMapper objectMapper, YoutubeOAuthService oauthService, YoutubeConnectionRepository connectionRepository) { this.restTemplate = restTemplate; this.objectMapper = objectMapper; this.oauthService = oauthService; this.connectionRepository = connectionRepository; } public YoutubeChannelAnalyticsDto analyze(Long connectionId, LocalDate startDate, LocalDate endDate) { validatePeriod(startDate, endDate); YoutubeConnection connection = connectionRepository.findById(connectionId) .orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾을 수 없습니다.")); String accessToken = oauthService.accessToken(connectionId); List daily = queryDaily(accessToken, startDate, endDate); List videoMetrics = queryVideoMetrics(accessToken, startDate, endDate); Map metadata = loadVideoMetadata(accessToken, videoMetrics.stream().map(RawVideoMetric::videoId).toList()); List videos = videoMetrics.stream() .map(metric -> toVideoDto(metric, metadata.get(metric.videoId()))) .toList(); List uploads = loadUploads(accessToken, connection.getChannelId()).stream() .filter(upload -> !upload.publishedAt().atZone(KOREA).toLocalDate().isBefore(startDate)) .filter(upload -> !upload.publishedAt().atZone(KOREA).toLocalDate().isAfter(endDate)) .toList(); long views = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::views).sum(); long watchMinutes = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::watchMinutes).sum(); long subscribersGained = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::subscribersGained).sum(); long subscribersLost = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::subscribersLost).sum(); long likes = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::likes).sum(); long comments = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::comments).sum(); double averageViewDuration = weightedAverageDuration(daily); return new YoutubeChannelAnalyticsDto( new YoutubeChannelAnalyticsDto.ChannelDto(connection.getId(), connection.getChannelId(), connection.getChannelTitle(), connection.getChannelHandle(), connection.getThumbnailUrl(), connection.getLastSyncedAt() == null ? null : connection.getLastSyncedAt().atZone(KOREA).toInstant()), new YoutubeChannelAnalyticsDto.PeriodDto(startDate, endDate, Duration.between(startDate.atStartOfDay(KOREA), endDate.plusDays(1).atStartOfDay(KOREA)).toDays()), new YoutubeChannelAnalyticsDto.SummaryDto(views, watchMinutes, averageViewDuration, subscribersGained, subscribersLost, subscribersGained - subscribersLost, likes, comments, uploads.size(), uploads.isEmpty() ? 0 : Math.round((double) views / uploads.size())), daily, videos, uploadRhythm(uploads) ); } public List videoTimeline(Long connectionId, String videoId, LocalDate startDate, LocalDate endDate) { if (videoId == null || videoId.isBlank()) throw new IllegalArgumentException("영상 ID가 필요합니다."); validatePeriod(startDate, endDate); String accessToken = oauthService.accessToken(connectionId); return queryDaily(accessToken, startDate, endDate, "video==" + videoId); } private List queryDaily(String accessToken, LocalDate startDate, LocalDate endDate) { return queryDaily(accessToken, startDate, endDate, null); } private List queryDaily(String accessToken, LocalDate startDate, LocalDate endDate, String filters) { JsonNode report = queryReport(accessToken, startDate, endDate, "day", METRICS, filters, "day", null); List result = new ArrayList<>(); for (JsonNode row : report.path("rows")) { if (!row.isArray() || row.size() < 8) continue; result.add(new YoutubeChannelAnalyticsDto.DailyDto( LocalDate.parse(row.path(0).asText()), row.path(1).asLong(), row.path(2).asLong(), row.path(3).asDouble(), row.path(4).asLong(), row.path(5).asLong(), row.path(6).asLong(), row.path(7).asLong())); } return result; } private List queryVideoMetrics(String accessToken, LocalDate startDate, LocalDate endDate) { JsonNode report = queryReport(accessToken, startDate, endDate, "video", METRICS, null, "-views", 50); List result = new ArrayList<>(); for (JsonNode row : report.path("rows")) { if (!row.isArray() || row.size() < 8) continue; result.add(new RawVideoMetric(row.path(0).asText(), row.path(1).asLong(), row.path(2).asLong(), row.path(3).asDouble(), row.path(4).asLong(), row.path(5).asLong(), row.path(6).asLong(), row.path(7).asLong())); } return result; } private JsonNode queryReport(String accessToken, LocalDate startDate, LocalDate endDate, String dimensions, String metrics, String filters, String sort, Integer maxResults) { UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(ANALYTICS_URL) .queryParam("ids", "channel==MINE") .queryParam("startDate", startDate) .queryParam("endDate", endDate) .queryParam("dimensions", dimensions) .queryParam("metrics", metrics) .queryParam("sort", sort); if (filters != null) builder.queryParam("filters", filters); if (maxResults != null) builder.queryParam("maxResults", maxResults); return getJson(builder.build().encode().toUri(), accessToken); } private Map loadVideoMetadata(String accessToken, List ids) { Map result = new HashMap<>(); for (int index = 0; index < ids.size(); index += 50) { List batch = ids.subList(index, Math.min(index + 50, ids.size())); if (batch.isEmpty()) continue; URI uri = UriComponentsBuilder.fromUriString(VIDEOS_URL) .queryParam("part", "snippet,contentDetails") .queryParam("id", String.join(",", batch)).build().encode().toUri(); for (JsonNode item : getJson(uri, accessToken).path("items")) { JsonNode snippet = item.path("snippet"); String publishedAt = snippet.path("publishedAt").asText(""); result.put(item.path("id").asText(), new VideoMetadata( snippet.path("title").asText("제목을 불러올 수 없는 영상"), thumbnailUrl(snippet), publishedAt.isBlank() ? null : Instant.parse(publishedAt))); } } return result; } private List loadUploads(String accessToken, String channelId) { URI channelUri = UriComponentsBuilder.fromUriString(CHANNELS_URL) .queryParam("part", "contentDetails").queryParam("id", channelId).build().encode().toUri(); String playlistId = getJson(channelUri, accessToken).path("items").path(0) .path("contentDetails").path("relatedPlaylists").path("uploads").asText(""); if (playlistId.isBlank()) return List.of(); List uploads = new ArrayList<>(); String pageToken = null; while (uploads.size() < MAX_UPLOADS) { UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(PLAYLIST_ITEMS_URL) .queryParam("part", "contentDetails").queryParam("playlistId", playlistId).queryParam("maxResults", 50); if (pageToken != null) builder.queryParam("pageToken", pageToken); JsonNode page = getJson(builder.build().encode().toUri(), accessToken); for (JsonNode item : page.path("items")) { String publishedAt = item.path("contentDetails").path("videoPublishedAt").asText(""); if (!publishedAt.isBlank()) uploads.add(new Upload(Instant.parse(publishedAt))); if (uploads.size() >= MAX_UPLOADS) break; } pageToken = page.path("nextPageToken").asText(""); if (pageToken.isBlank()) break; } return uploads; } private YoutubeChannelAnalyticsDto.UploadRhythmDto uploadRhythm(List uploads) { long[] weekdays = new long[7]; long[] hours = new long[24]; List published = uploads.stream().map(Upload::publishedAt).sorted().toList(); for (Instant instant : published) { var dateTime = instant.atZone(KOREA); weekdays[dateTime.getDayOfWeek().getValue() - 1]++; hours[dateTime.getHour()]++; } List weekdayCounts = new ArrayList<>(); List hourCounts = new ArrayList<>(); for (int i = 0; i < weekdays.length; i++) weekdayCounts.add(new YoutubeChannelAnalyticsDto.BucketDto(i + 1, weekdays[i])); for (int i = 0; i < hours.length; i++) hourCounts.add(new YoutubeChannelAnalyticsDto.BucketDto(i, hours[i])); return new YoutubeChannelAnalyticsDto.UploadRhythmDto(uploads.size(), averageGap(published), mostActiveIndex(weekdays) + 1, mostActiveIndex(hours), weekdayCounts, hourCounts); } private double averageGap(List published) { if (published.size() < 2) return 0; long minutes = 0; for (int i = 1; i < published.size(); i++) minutes += Duration.between(published.get(i - 1), published.get(i)).toMinutes(); return Math.round((minutes / 1440.0 / (published.size() - 1)) * 10) / 10.0; } private int mostActiveIndex(long[] counts) { int best = 0; for (int i = 1; i < counts.length; i++) if (counts[i] > counts[best]) best = i; return best; } private YoutubeChannelAnalyticsDto.VideoDto toVideoDto(RawVideoMetric metric, VideoMetadata metadata) { return new YoutubeChannelAnalyticsDto.VideoDto(metric.videoId(), metadata == null ? "제목을 불러올 수 없는 영상" : metadata.title(), metadata == null ? "" : metadata.thumbnailUrl(), metadata == null ? null : metadata.publishedAt(), metric.views(), metric.watchMinutes(), metric.averageViewDurationSeconds(), metric.subscribersGained(), metric.subscribersLost(), metric.subscribersGained() - metric.subscribersLost(), metric.likes(), metric.comments()); } private double weightedAverageDuration(List daily) { long totalViews = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::views).sum(); if (totalViews == 0) return 0; double totalDuration = daily.stream().mapToDouble(day -> day.averageViewDurationSeconds() * day.views()).sum(); return Math.round(totalDuration / totalViews * 10) / 10.0; } private JsonNode getJson(URI uri, String accessToken) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); String body; try { body = restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(headers), String.class).getBody(); } catch (HttpStatusCodeException e) { if (e.getStatusCode().value() == 401 || e.getStatusCode().value() == 403) { throw new IllegalStateException("YouTube 분석 권한이 만료되었거나 부족합니다. 채널을 다시 연결해 주세요.", e); } throw new IllegalStateException("YouTube 분석 데이터를 불러오지 못했습니다.", e); } try { return objectMapper.readTree(body); } catch (Exception e) { throw new IllegalStateException("YouTube 응답을 해석하지 못했습니다.", e); } } private String thumbnailUrl(JsonNode snippet) { JsonNode thumbnails = snippet.path("thumbnails"); String high = thumbnails.path("high").path("url").asText(""); return high.isBlank() ? thumbnails.path("medium").path("url").asText("") : high; } private void validatePeriod(LocalDate startDate, LocalDate endDate) { if (startDate == null || endDate == null) throw new IllegalArgumentException("시작일과 종료일을 모두 선택하세요."); if (startDate.isAfter(endDate)) throw new IllegalArgumentException("시작일은 종료일보다 앞서야 합니다."); if (endDate.isAfter(LocalDate.now(KOREA))) throw new IllegalArgumentException("종료일은 오늘 이후로 선택할 수 없습니다."); if (startDate.plusDays(MAX_RANGE_DAYS - 1L).isBefore(endDate)) { throw new IllegalArgumentException("분석 기간은 최대 " + MAX_RANGE_DAYS + "일입니다."); } } private record RawVideoMetric(String videoId, long views, long watchMinutes, double averageViewDurationSeconds, long subscribersGained, long subscribersLost, long likes, long comments) { } private record VideoMetadata(String title, String thumbnailUrl, Instant publishedAt) { } private record Upload(Instant publishedAt) { } }