feat: 업로드 성과 일별 조회수 분석 추가
This commit is contained in:
parent
17b8a0c74d
commit
6151228248
@ -12,6 +12,7 @@ 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.client.HttpStatusCodeException;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.net.URI;
|
||||
@ -28,7 +29,7 @@ public class YoutubeOAuthService {
|
||||
private static final String AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
private static final String TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
private static final String CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels";
|
||||
private static final String SCOPE = "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly";
|
||||
private static final String SCOPE = "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/yt-analytics.readonly";
|
||||
|
||||
@Qualifier("restTemplate")
|
||||
private final RestTemplate restTemplate;
|
||||
@ -121,7 +122,9 @@ public class YoutubeOAuthService {
|
||||
LocalDate today = LocalDate.now(zone);
|
||||
Map<LocalDate, Integer> counts = new LinkedHashMap<>();
|
||||
for (int i = 6; i >= 0; i--) counts.put(today.minusDays(i), 0);
|
||||
Map<LocalDate, Long> dailyViews = loadDailyViews(accessToken, today.minusDays(6), today);
|
||||
List<RecentUploadDto> recent = new ArrayList<>();
|
||||
List<String> recentVideoIds = new ArrayList<>();
|
||||
for (JsonNode item : items) {
|
||||
String publishedText = item.path("contentDetails").path("videoPublishedAt").asText(
|
||||
item.path("snippet").path("publishedAt").asText(""));
|
||||
@ -130,14 +133,22 @@ public class YoutubeOAuthService {
|
||||
LocalDate date = publishedAt.atZone(zone).toLocalDate();
|
||||
if (counts.containsKey(date)) {
|
||||
counts.put(date, counts.get(date) + 1);
|
||||
recent.add(new RecentUploadDto(item.path("contentDetails").path("videoId").asText(""),
|
||||
item.path("snippet").path("title").asText(""), publishedAt));
|
||||
String videoId = item.path("contentDetails").path("videoId").asText("");
|
||||
recentVideoIds.add(videoId);
|
||||
recent.add(new RecentUploadDto(videoId, item.path("snippet").path("title").asText(""), publishedAt, 0));
|
||||
}
|
||||
}
|
||||
Map<String, Long> videoViews = loadVideoViews(accessToken, recentVideoIds);
|
||||
recent = recent.stream().map(video -> new RecentUploadDto(video.videoId(), video.title(),
|
||||
video.publishedAt(), videoViews.getOrDefault(video.videoId(), 0L))).toList();
|
||||
connection.markSynced();
|
||||
int total = counts.values().stream().mapToInt(Integer::intValue).sum();
|
||||
return new SyncDto(total, counts.entrySet().stream()
|
||||
.map(e -> new DailyCountDto(e.getKey(), e.getValue())).toList(), recent);
|
||||
int totalUploads = counts.values().stream().mapToInt(Integer::intValue).sum();
|
||||
long totalViews = dailyViews.values().stream().mapToLong(Long::longValue).sum();
|
||||
long recentVideoViews = recent.stream().mapToLong(RecentUploadDto::viewCount).sum();
|
||||
return new SyncDto(totalUploads, totalViews, recentVideoViews,
|
||||
totalUploads == 0 ? 0 : Math.round((double) totalViews / totalUploads),
|
||||
counts.entrySet().stream().map(e -> new DailyCountDto(e.getKey(), e.getValue(),
|
||||
dailyViews.getOrDefault(e.getKey(), 0L))).toList(), recent);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@ -188,6 +199,45 @@ public class YoutubeOAuthService {
|
||||
return readJson(body).path("items");
|
||||
}
|
||||
|
||||
private Map<LocalDate, Long> loadDailyViews(String accessToken, LocalDate startDate, LocalDate endDate) {
|
||||
URI uri = UriComponentsBuilder.fromUriString("https://youtubeanalytics.googleapis.com/v2/reports")
|
||||
.queryParam("ids", "channel==MINE").queryParam("startDate", startDate)
|
||||
.queryParam("endDate", endDate).queryParam("metrics", "views")
|
||||
.queryParam("dimensions", "day").queryParam("sort", "day").build().encode().toUri();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(accessToken);
|
||||
String body;
|
||||
try {
|
||||
body = restTemplate.exchange(uri, org.springframework.http.HttpMethod.GET,
|
||||
new HttpEntity<>(headers), String.class).getBody();
|
||||
} catch (HttpStatusCodeException e) {
|
||||
if (e.getStatusCode().value() == 401 || e.getStatusCode().value() == 403) {
|
||||
throw new IllegalStateException("일별 조회수 분석 권한이 없습니다. 채널 연결을 해제한 뒤 다시 연결해 주세요.", e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
Map<LocalDate, Long> views = new LinkedHashMap<>();
|
||||
for (JsonNode row : readJson(body).path("rows")) {
|
||||
if (row.isArray() && row.size() >= 2) views.put(LocalDate.parse(row.get(0).asText()), row.get(1).asLong());
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
private Map<String, Long> loadVideoViews(String accessToken, List<String> videoIds) {
|
||||
if (videoIds.isEmpty()) return Map.of();
|
||||
URI uri = UriComponentsBuilder.fromUriString("https://www.googleapis.com/youtube/v3/videos")
|
||||
.queryParam("part", "statistics").queryParam("id", String.join(",", videoIds)).build().encode().toUri();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setBearerAuth(accessToken);
|
||||
String body = restTemplate.exchange(uri, org.springframework.http.HttpMethod.GET,
|
||||
new HttpEntity<>(headers), String.class).getBody();
|
||||
Map<String, Long> views = new LinkedHashMap<>();
|
||||
for (JsonNode video : readJson(body).path("items")) {
|
||||
views.put(video.path("id").asText(), video.path("statistics").path("viewCount").asLong());
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
private JsonNode loadChannel(String accessToken) {
|
||||
URI uri = UriComponentsBuilder.fromUriString(CHANNELS_URL)
|
||||
.queryParam("part", "snippet,contentDetails,statistics")
|
||||
@ -227,8 +277,9 @@ public class YoutubeOAuthService {
|
||||
c.getThumbnailUrl(), c.getTokenStatus(), c.getLastSyncedAt());
|
||||
}
|
||||
}
|
||||
public record SyncDto(int recentSevenDayCount, List<DailyCountDto> dailyCounts,
|
||||
public record SyncDto(int recentSevenDayCount, long sevenDayViews, long recentVideoViews,
|
||||
long viewsPerUpload, List<DailyCountDto> dailyCounts,
|
||||
List<RecentUploadDto> recentUploads) { }
|
||||
public record DailyCountDto(LocalDate date, int count) { }
|
||||
public record RecentUploadDto(String videoId, String title, Instant publishedAt) { }
|
||||
public record DailyCountDto(LocalDate date, int count, long views) { }
|
||||
public record RecentUploadDto(String videoId, String title, Instant publishedAt, long viewCount) { }
|
||||
}
|
||||
|
||||
@ -20,9 +20,11 @@
|
||||
.us-channel-name { font-weight:750; font-size:1.05rem; }
|
||||
.us-muted { color:var(--text-2); font-size:.82rem; }
|
||||
.us-select { margin-left:auto; border:1px solid var(--border); background:var(--surface-2); color:var(--text); padding:.55rem 2rem .55rem .7rem; border-radius:var(--r-sm); }
|
||||
.us-rhythm { display:grid; grid-template-columns:repeat(7,1fr); gap:5px; margin-top:1rem; height:58px; align-items:end; }
|
||||
.us-day { height:var(--h); min-height:7px; background:var(--studio-soft); border-top:2px solid var(--studio); position:relative; }
|
||||
.us-day span { position:absolute; top:calc(100% + 6px); left:50%; transform:translateX(-50%); font:600 .58rem/1 var(--font-mono); color:var(--text-3); }
|
||||
.us-rhythm { display:grid; grid-template-columns:repeat(7,1fr); gap:7px; margin-top:1rem; height:92px; align-items:end; }
|
||||
.us-day { height:var(--h); min-height:9px; background:linear-gradient(180deg,rgba(109,93,252,.28),var(--studio-soft)); border-top:2px solid var(--studio); position:relative; }
|
||||
.us-day-label { position:absolute; top:calc(100% + 6px); left:50%; transform:translateX(-50%); font:600 .58rem/1 var(--font-mono); color:var(--text-3); }
|
||||
.us-day-views { position:absolute; left:5px; top:5px; font:750 .62rem/1 var(--font-mono); color:var(--text); white-space:nowrap; }
|
||||
.us-day-count { position:absolute; right:4px; top:-20px; color:var(--studio); font:700 .58rem/1 var(--font-mono); white-space:nowrap; }
|
||||
.us-folder { border-left:1px solid var(--border); padding:1.15rem 1.25rem; display:flex; flex-direction:column; justify-content:center; }
|
||||
.us-folder-title { display:flex; align-items:center; gap:.45rem; font-weight:700; }
|
||||
.us-folder-path { color:var(--text-2); font:500 .7rem/1.5 var(--font-mono); margin:.55rem 0 .8rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@ -107,7 +109,7 @@
|
||||
</section>
|
||||
|
||||
<section class="us-summary" aria-label="업로드 요약">
|
||||
<div class="us-stat"><strong id="totalCount">0</strong><span>영상 및 작업</span></div><div class="us-stat"><strong id="waitCount">0</strong><span>업로드 대기</span></div><div class="us-stat"><strong id="recentCount">—</strong><span>최근 7일 발행</span></div><div class="us-stat"><strong id="nextSchedule">—</strong><span>다음 예약</span></div>
|
||||
<div class="us-stat"><strong id="sevenDayViews">—</strong><span>7일 조회수 합계</span></div><div class="us-stat"><strong id="recentCount">—</strong><span>7일 업로드</span></div><div class="us-stat"><strong id="viewsPerUpload">—</strong><span>업로드 1개당 조회수</span></div><div class="us-stat"><strong id="recentVideoViews">—</strong><span>최근 영상 누적 조회수</span></div>
|
||||
</section>
|
||||
|
||||
<div class="us-work">
|
||||
@ -155,15 +157,11 @@
|
||||
const q=document.getElementById('videoSearch').value.toLowerCase();
|
||||
const list=videos.filter(v=>(filter==='all'||v.status===filter)&&v.name.toLowerCase().includes(q));
|
||||
document.getElementById('videoList').innerHTML=list.length?list.map(v=>`<button type="button" class="us-card ${v.id===selectedId?'selected':''}" onclick="selectVideo('${v.id}')"><span class="us-check">${v.id===selectedId?'✓':''}</span><span class="us-thumb"></span><span class="us-title"><strong>${esc(v.name)}</strong><span>${esc(v.meta)}</span></span><span class="us-badge ${v.status}">${v.status==='wait'?'↑':v.status==='failed'?'!':v.status==='uploading'?'…':'✓'} ${v.label}</span><span class="us-time">${esc(v.time||'')}</span></button>`).join(''):'<div class="us-empty">폴더를 선택하거나 YouTube 동기화를 실행해 주세요.</div>';
|
||||
document.getElementById('totalCount').textContent=videos.length;
|
||||
document.getElementById('waitCount').textContent=videos.filter(v=>v.status==='wait').length;
|
||||
}
|
||||
function rebuildVideos(){
|
||||
const jobVideoIds=new Set(uploadJobs.map(v=>v.youtubeVideoId).filter(Boolean));
|
||||
videos=[...localVideos,...uploadJobs,...recentUploads.filter(v=>!jobVideoIds.has(v.youtubeVideoId))];
|
||||
if(selectedId&&!videos.some(v=>v.id===selectedId))selectedId=null;
|
||||
const schedules=uploadJobs.filter(v=>v.publishAt&&v.status==='scheduled').sort((a,b)=>a.publishAt.localeCompare(b.publishAt));
|
||||
document.getElementById('nextSchedule').textContent=schedules.length?new Date(schedules[0].publishAt).toLocaleString('ko-KR',{month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}):'—';
|
||||
renderVideos();
|
||||
}
|
||||
function selectVideo(id){
|
||||
@ -251,16 +249,20 @@
|
||||
const response=await fetch('/api/youtube/connections/'+id+'/sync',{method:'POST'});const body=await response.json();
|
||||
if(!response.ok||!body.success)throw new Error(body.message||'동기화 실패');
|
||||
document.getElementById('recentCount').textContent=body.data.recentSevenDayCount;
|
||||
document.getElementById('sevenDayViews').textContent=compactNumber(body.data.sevenDayViews);
|
||||
document.getElementById('viewsPerUpload').textContent=compactNumber(body.data.viewsPerUpload);
|
||||
document.getElementById('recentVideoViews').textContent=compactNumber(body.data.recentVideoViews);
|
||||
recentUploads=(body.data.recentUploads||[]).map(v=>({id:'youtube-'+v.videoId,name:v.title,title:v.title,
|
||||
meta:'YouTube 게시 영상 · '+v.videoId,status:'done',label:'게시됨',youtubeVideoId:v.videoId,
|
||||
meta:'조회수 '+Number(v.viewCount||0).toLocaleString('ko-KR')+' · '+v.videoId,status:'done',label:'게시됨',youtubeVideoId:v.videoId,
|
||||
time:new Date(v.publishedAt).toLocaleDateString('ko-KR')}));
|
||||
renderRhythm(body.data.dailyCounts);await loadConnections();rebuildVideos();if(!silent)showToast('최근 7일 업로드 '+body.data.recentSevenDayCount+'개를 동기화했습니다.');
|
||||
}catch(e){showToast(e.message||'YouTube 동기화에 실패했습니다.');}
|
||||
}
|
||||
function renderRhythm(days){
|
||||
const max=Math.max(1,...days.map(d=>d.count));const labels=['일','월','화','수','목','금','토'];
|
||||
document.getElementById('uploadRhythm').innerHTML=days.map((d,i)=>{const date=new Date(d.date+'T00:00:00');const label=i===days.length-1?'오늘':labels[date.getDay()];const height=Math.max(8,Math.round(d.count/max*88));return `<div class="us-day" style="--h:${height}%" title="${d.date} · ${d.count}개"><span>${label}</span></div>`;}).join('');
|
||||
const max=Math.max(1,...days.map(d=>d.views));const labels=['일','월','화','수','목','금','토'];
|
||||
document.getElementById('uploadRhythm').innerHTML=days.map((d,i)=>{const date=new Date(d.date+'T00:00:00');const label=i===days.length-1?'오늘':labels[date.getDay()];const height=Math.max(10,Math.round(Math.sqrt(d.views/max)*88));return `<div class="us-day" style="--h:${height}%" title="${d.date} · 조회수 ${Number(d.views).toLocaleString('ko-KR')} · 업로드 ${d.count}개"><span class="us-day-views">${compactNumber(d.views)}</span><span class="us-day-count">↑ ${d.count}</span><span class="us-day-label">${label}</span></div>`;}).join('');
|
||||
}
|
||||
function compactNumber(value){return new Intl.NumberFormat('ko-KR',{notation:'compact',maximumFractionDigits:1}).format(Number(value||0));}
|
||||
async function submitUpload(e){
|
||||
e.preventDefault();
|
||||
const connectionId=document.getElementById('channelSelect').value;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user