fix: 유튜브 최근 업로드 실제 동기화

This commit is contained in:
hehihoho3@gmail.com 2026-08-31 14:15:27 +09:00
parent 6e4b23e849
commit 536d87aba2
5 changed files with 111 additions and 6 deletions

View File

@ -69,4 +69,9 @@ public class YoutubeConnection {
this.tokenStatus = "ACTIVE";
this.lastSyncedAt = LocalDateTime.now();
}
public void markSynced() {
this.lastSyncedAt = LocalDateTime.now();
this.tokenStatus = "ACTIVE";
}
}

View File

@ -43,6 +43,22 @@ public class YoutubeCredentialCipher {
}
}
public String decrypt(String encodedPayload) {
try {
byte[] payload = Base64.getDecoder().decode(encodedPayload);
if (payload.length <= IV_LENGTH) throw new IllegalArgumentException("잘못된 암호문입니다.");
byte[] iv = new byte[IV_LENGTH];
byte[] encrypted = new byte[payload.length - IV_LENGTH];
System.arraycopy(payload, 0, iv, 0, IV_LENGTH);
System.arraycopy(payload, IV_LENGTH, encrypted, 0, encrypted.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key(), "AES"), new GCMParameterSpec(TAG_LENGTH, iv));
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
} catch (GeneralSecurityException | IllegalArgumentException e) {
throw new IllegalStateException("YouTube OAuth 토큰을 복호화하지 못했습니다.", e);
}
}
private byte[] key() {
if (encodedKey == null || encodedKey.isBlank()) {
throw new IllegalStateException("YOUTUBE_OAUTH_ENCRYPTION_KEY가 설정되지 않았습니다.");

View File

@ -81,6 +81,12 @@ public class YoutubeOAuthController {
return ApiResponse.ok(null);
}
@PostMapping("/connections/{id}/sync")
@ResponseBody
public ApiResponse<YoutubeOAuthService.SyncDto> sync(@PathVariable Long id) {
return ApiResponse.ok(service.sync(id));
}
private ResponseEntity<Void> redirect(String location) {
return ResponseEntity.status(302).location(URI.create(location)).build();
}

View File

@ -15,6 +15,12 @@ import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.List;
@Service
@ -101,6 +107,39 @@ public class YoutubeOAuthService {
return repository.count() > 0;
}
@Transactional
public SyncDto sync(Long id) {
YoutubeConnection connection = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾지 못했습니다."));
String accessToken = refreshAccessToken(cipher.decrypt(connection.getEncryptedRefreshToken()));
JsonNode channel = loadChannel(accessToken);
String uploadsPlaylistId = channel.path("contentDetails").path("relatedPlaylists").path("uploads").asText("");
if (uploadsPlaylistId.isBlank()) throw new IllegalStateException("채널의 업로드 재생목록을 찾지 못했습니다.");
JsonNode items = loadRecentUploads(accessToken, uploadsPlaylistId);
ZoneId zone = ZoneId.of("Asia/Seoul");
LocalDate today = LocalDate.now(zone);
Map<LocalDate, Integer> counts = new LinkedHashMap<>();
for (int i = 6; i >= 0; i--) counts.put(today.minusDays(i), 0);
List<RecentUploadDto> recent = new ArrayList<>();
for (JsonNode item : items) {
String publishedText = item.path("contentDetails").path("videoPublishedAt").asText(
item.path("snippet").path("publishedAt").asText(""));
if (publishedText.isBlank()) continue;
Instant publishedAt = Instant.parse(publishedText);
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));
}
}
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);
}
@Transactional
public void disconnect(Long id) {
repository.deleteById(id);
@ -119,6 +158,30 @@ public class YoutubeOAuthService {
return readJson(body);
}
private String refreshAccessToken(String refreshToken) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("client_id", clientId);
form.add("client_secret", clientSecret);
form.add("refresh_token", refreshToken);
form.add("grant_type", "refresh_token");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
JsonNode token = readJson(restTemplate.postForObject(TOKEN_URL, new HttpEntity<>(form, headers), String.class));
return requiredText(token, "access_token", "YouTube access token을 갱신하지 못했습니다.");
}
private JsonNode loadRecentUploads(String accessToken, String playlistId) {
URI uri = UriComponentsBuilder.fromUriString("https://www.googleapis.com/youtube/v3/playlistItems")
.queryParam("part", "snippet,contentDetails")
.queryParam("playlistId", playlistId)
.queryParam("maxResults", 50).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();
return readJson(body).path("items");
}
private JsonNode loadChannel(String accessToken) {
URI uri = UriComponentsBuilder.fromUriString(CHANNELS_URL)
.queryParam("part", "snippet,contentDetails,statistics")
@ -158,4 +221,8 @@ public class YoutubeOAuthService {
c.getThumbnailUrl(), c.getTokenStatus(), c.getLastSyncedAt());
}
}
public record SyncDto(int recentSevenDayCount, List<DailyCountDto> dailyCounts,
List<RecentUploadDto> recentUploads) { }
public record DailyCountDto(LocalDate date, int count) { }
public record RecentUploadDto(String videoId, String title, Instant publishedAt) { }
}

View File

@ -96,15 +96,13 @@
<section class="us-top" aria-label="채널과 폴더 현황">
<div class="us-channel">
<div class="us-channel-line"><div class="us-avatar" id="channelAvatar">Y</div><div><div class="us-channel-name" id="channelName">YouTube 채널을 연결하세요</div><div class="us-muted" id="channelMeta">Google OAuth 연결이 필요합니다.</div></div><select class="us-select" id="channelSelect" aria-label="YouTube 채널" onchange="selectChannel(this.value)"><option value="">연결된 채널 없음</option></select></div>
<div class="us-rhythm" aria-label="최근 7일 업로드 8개">
<div class="us-day" style="--h:22%"><span>월</span></div><div class="us-day" style="--h:60%"><span>화</span></div><div class="us-day" style="--h:36%"><span>수</span></div><div class="us-day" style="--h:82%"><span>목</span></div><div class="us-day" style="--h:50%"><span>금</span></div><div class="us-day" style="--h:18%"><span>토</span></div><div class="us-day" style="--h:70%"><span>오늘</span></div>
</div>
<div class="us-rhythm" id="uploadRhythm" aria-label="최근 7일 업로드"><div class="us-day" style="--h:8%"><span>불러오는 중</span></div></div>
</div>
<div class="us-folder"><div class="us-folder-title"><i data-lucide="folder-check" style="width:17px;color:var(--success)"></i> <span id="folderTitle">CapCut 내보내기 폴더</span></div><div class="us-folder-path" id="folderPath">폴더를 연결하면 영상 파일을 바로 비교합니다.</div><button class="us-button" type="button" onclick="chooseFolder()"><i data-lucide="folder-open"></i> 폴더 선택</button><input id="folderFallback" type="file" accept="video/*" webkitdirectory multiple hidden></div>
</section>
<section class="us-summary" aria-label="업로드 요약">
<div class="us-stat"><strong id="totalCount">4</strong><span>보관함 영상</span></div><div class="us-stat"><strong id="waitCount">2</strong><span>업로드 대기</span></div><div class="us-stat"><strong>8</strong><span>최근 7일 발행</span></div><div class="us-stat"><strong>오늘 18:30</strong><span>다음 예약</span></div>
<div class="us-stat"><strong id="totalCount">4</strong><span>보관함 영상</span></div><div class="us-stat"><strong id="waitCount">2</strong><span>업로드 대기</span></div><div class="us-stat"><strong id="recentCount">—</strong><span>최근 7일 발행</span></div><div class="us-stat"><strong>오늘 18:30</strong><span>다음 예약</span></div>
</section>
<div class="us-work">
@ -203,14 +201,27 @@
document.getElementById('channelMeta').textContent=c?(c.handle||c.channelId)+' · 토큰 '+(c.tokenStatus==='ACTIVE'?'정상':'확인 필요'):'Google OAuth 연결이 필요합니다.';
document.getElementById('channelAvatar').textContent=c?c.title.slice(0,1).toUpperCase():'Y';
}
function syncStudio(){if(!connections.length){connectYoutube();return;}loadConnections().then(()=>showToast('YouTube 채널 정보를 동기화했습니다.'));}
async function syncStudio(silent=false){
if(!connections.length){if(!silent)connectYoutube();return;}
const id=document.getElementById('channelSelect').value;if(!id)return;
try{
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;
renderRhythm(body.data.dailyCounts);await loadConnections();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('');
}
function submitUpload(e){e.preventDefault();if(!selectedId){showToast('먼저 영상을 선택해 주세요.');return;}if(uploadMode==='schedule'&&!document.getElementById('scheduleAt').value){showToast('공개 예정 시간을 선택해 주세요.');return;}const v=videos.find(x=>x.id===selectedId);v.status=uploadMode==='schedule'?'done':'done';v.label=uploadMode==='schedule'?'예약됨':'업로드 중';renderVideos();showToast(uploadMode==='schedule'?'예약 업로드를 등록했습니다.':'업로드를 시작했습니다.');}
function showToast(message){const t=document.getElementById('studioToast');t.textContent=message;t.style.display='block';clearTimeout(t.timer);t.timer=setTimeout(()=>t.style.display='none',2400);}
const oauthResult=new URLSearchParams(location.search).get('youtube');
if(oauthResult){const messages={connected:'YouTube 채널을 연결했습니다.',denied:'YouTube 권한 요청이 취소되었습니다.','invalid-state':'안전하지 않은 OAuth 응답을 차단했습니다.','missing-code':'Google 인증 코드가 없습니다.',failed:'YouTube 채널 연결에 실패했습니다.'};setTimeout(()=>showToast(messages[oauthResult]||'YouTube 연결을 확인해 주세요.'),100);history.replaceState(null,'',location.pathname);}
renderVideos();
loadOAuthStatus();
loadConnections();
loadConnections().then(()=>{if(connections.length)syncStudio(true);});
</script>
</th:block>
</body>