feat: 화면에서 유튜브 OAuth 연결 지원

This commit is contained in:
hehihoho3@gmail.com 2026-08-31 11:52:43 +09:00
parent bc416ab7bb
commit d74bbaf657
10 changed files with 553 additions and 3 deletions

1
.gitignore vendored
View File

@ -12,6 +12,7 @@ out/
# Secrets (DO NOT COMMIT) # Secrets (DO NOT COMMIT)
src/main/resources/credentials.json src/main/resources/credentials.json
src/main/resources/application-local.yml src/main/resources/application-local.yml
config/youtube-oauth.properties
tokens/ tokens/
*.log *.log

View File

@ -0,0 +1,72 @@
package com.hlab.yanalyst.domain.upload;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.EntityListeners;
import java.time.LocalDateTime;
@Entity
@Table(name = "youtube_upload_connections")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class YoutubeConnection {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 64)
private String channelId;
@Column(nullable = false)
private String channelTitle;
private String channelHandle;
@Column(length = 2083)
private String thumbnailUrl;
@Column(nullable = false, columnDefinition = "TEXT")
private String encryptedRefreshToken;
@Column(nullable = false, length = 20)
private String tokenStatus = "ACTIVE";
private LocalDateTime lastSyncedAt;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
public YoutubeConnection(String channelId, String channelTitle, String channelHandle,
String thumbnailUrl, String encryptedRefreshToken) {
this.channelId = channelId;
update(channelTitle, channelHandle, thumbnailUrl, encryptedRefreshToken);
}
public void update(String channelTitle, String channelHandle, String thumbnailUrl,
String encryptedRefreshToken) {
this.channelTitle = channelTitle;
this.channelHandle = channelHandle;
this.thumbnailUrl = thumbnailUrl;
if (encryptedRefreshToken != null && !encryptedRefreshToken.isBlank()) {
this.encryptedRefreshToken = encryptedRefreshToken;
}
this.tokenStatus = "ACTIVE";
this.lastSyncedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,11 @@
package com.hlab.yanalyst.domain.upload;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface YoutubeConnectionRepository extends JpaRepository<YoutubeConnection, Long> {
Optional<YoutubeConnection> findByChannelId(String channelId);
List<YoutubeConnection> findAllByOrderByIdAsc();
}

View File

@ -0,0 +1,56 @@
package com.hlab.yanalyst.domain.upload;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
@Component
public class YoutubeCredentialCipher {
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH = 128;
private volatile String encodedKey;
private final SecureRandom secureRandom = new SecureRandom();
public YoutubeCredentialCipher(@Value("${youtube.oauth.encryption-key:}") String encodedKey) {
this.encodedKey = encodedKey;
}
public void configure(String encodedKey) {
this.encodedKey = encodedKey;
}
public String encrypt(String plaintext) {
try {
byte[] key = key();
byte[] iv = new byte[IV_LENGTH];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(TAG_LENGTH, iv));
byte[] encrypted = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] payload = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, payload, 0, iv.length);
System.arraycopy(encrypted, 0, payload, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(payload);
} 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가 설정되지 않았습니다.");
}
byte[] decoded = Base64.getDecoder().decode(encodedKey);
if (decoded.length != 32) {
throw new IllegalStateException("YOUTUBE_OAUTH_ENCRYPTION_KEY는 Base64로 인코딩한 32바이트 키여야 합니다.");
}
return decoded;
}
}

View File

@ -0,0 +1,91 @@
package com.hlab.yanalyst.domain.upload;
import com.hlab.yanalyst.global.common.ApiResponse;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import java.net.URI;
import java.util.List;
import java.util.UUID;
@Controller
@RequestMapping("/api/youtube")
@RequiredArgsConstructor
public class YoutubeOAuthController {
private static final String STATE_SESSION_KEY = "youtube.oauth.state";
private final YoutubeOAuthService service;
private final YoutubeOAuthLocalSettings localSettings;
@GetMapping("/oauth/start")
public ResponseEntity<Void> start(HttpSession session) {
String state = UUID.randomUUID().toString();
session.setAttribute(STATE_SESSION_KEY, state);
return ResponseEntity.status(302).location(service.authorizationUri(state)).build();
}
@GetMapping("/oauth/callback")
public ResponseEntity<Void> callback(@RequestParam(required = false) String code,
@RequestParam(required = false) String state,
@RequestParam(required = false) String error,
HttpSession session) {
String expected = (String) session.getAttribute(STATE_SESSION_KEY);
session.removeAttribute(STATE_SESSION_KEY);
if (error != null) return redirect("/upload-studio?youtube=denied");
if (expected == null || !expected.equals(state)) return redirect("/upload-studio?youtube=invalid-state");
if (code == null || code.isBlank()) return redirect("/upload-studio?youtube=missing-code");
try {
service.connect(code);
return redirect("/upload-studio?youtube=connected");
} catch (RuntimeException e) {
return redirect("/upload-studio?youtube=failed");
}
}
@GetMapping("/connections")
@ResponseBody
public ApiResponse<List<YoutubeOAuthService.ConnectionDto>> connections() {
return ApiResponse.ok(service.connections());
}
@GetMapping("/settings/status")
@ResponseBody
public ApiResponse<YoutubeOAuthLocalSettings.Status> settingsStatus() {
return ApiResponse.ok(localSettings.status());
}
@PostMapping("/settings")
@ResponseBody
public ApiResponse<YoutubeOAuthLocalSettings.Status> saveSettings(@RequestBody SettingsRequest body,
HttpServletRequest request) {
if (!request.getRemoteAddr().equals("127.0.0.1") && !request.getRemoteAddr().equals("0:0:0:0:0:0:0:1")
&& !request.getRemoteAddr().equals("::1")) {
throw new IllegalStateException("YouTube 키 설정은 서버 컴퓨터에서만 변경할 수 있습니다.");
}
localSettings.save(body.clientId(), body.clientSecret(), body.redirectUri());
return ApiResponse.ok(localSettings.status());
}
@DeleteMapping("/connections/{id}")
@ResponseBody
public ApiResponse<Void> disconnect(@PathVariable Long id) {
service.disconnect(id);
return ApiResponse.ok(null);
}
private ResponseEntity<Void> redirect(String location) {
return ResponseEntity.status(302).location(URI.create(location)).build();
}
public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { }
}

View File

@ -0,0 +1,93 @@
package com.hlab.yanalyst.domain.upload;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Properties;
@Component
public class YoutubeOAuthLocalSettings {
private final Path path;
private final YoutubeOAuthService oauthService;
private final YoutubeCredentialCipher cipher;
public YoutubeOAuthLocalSettings(
@Value("${youtube.oauth.settings-file:config/youtube-oauth.properties}") String settingsFile,
YoutubeOAuthService oauthService,
YoutubeCredentialCipher cipher) {
this.path = Path.of(settingsFile).toAbsolutePath().normalize();
this.oauthService = oauthService;
this.cipher = cipher;
}
@PostConstruct
void load() {
if (!Files.isRegularFile(path)) return;
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(path)) {
properties.load(input);
apply(properties);
} catch (IOException e) {
throw new IllegalStateException("YouTube OAuth 로컬 설정을 읽지 못했습니다.", e);
}
}
public synchronized void save(String clientId, String clientSecret, String redirectUri) {
if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) {
throw new IllegalArgumentException("Client ID와 Client Secret을 모두 입력해 주세요.");
}
if (redirectUri == null || !redirectUri.matches("https?://[^\\s]+/api/youtube/oauth/callback")) {
throw new IllegalArgumentException("올바른 OAuth 콜백 주소를 입력해 주세요.");
}
Properties properties = new Properties();
properties.setProperty("clientId", clientId.trim());
properties.setProperty("clientSecret", clientSecret.trim());
properties.setProperty("redirectUri", redirectUri.trim());
properties.setProperty("encryptionKey", existingOrNewKey());
try {
if (path.getParent() != null) Files.createDirectories(path.getParent());
try (OutputStream output = Files.newOutputStream(path, StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) {
properties.store(output, "Local YouTube OAuth settings - never commit this file");
}
apply(properties);
} catch (IOException e) {
throw new IllegalStateException("YouTube OAuth 설정을 저장하지 못했습니다.", e);
}
}
public Status status() {
return new Status(oauthService.isConfigured(), oauthService.redirectUri());
}
private void apply(Properties properties) {
oauthService.configure(properties.getProperty("clientId", ""),
properties.getProperty("clientSecret", ""), properties.getProperty("redirectUri", ""));
cipher.configure(properties.getProperty("encryptionKey", ""));
}
private String existingOrNewKey() {
if (Files.isRegularFile(path)) {
Properties existing = new Properties();
try (InputStream input = Files.newInputStream(path)) {
existing.load(input);
String key = existing.getProperty("encryptionKey", "");
if (!key.isBlank()) return key;
} catch (IOException ignored) { }
}
byte[] key = new byte[32];
new SecureRandom().nextBytes(key);
return Base64.getEncoder().encodeToString(key);
}
public record Status(boolean configured, String redirectUri) { }
}

View File

@ -0,0 +1,156 @@
package com.hlab.yanalyst.domain.upload;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
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.util.UriComponentsBuilder;
import java.net.URI;
import java.util.List;
@Service
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";
@Qualifier("restTemplate")
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final YoutubeConnectionRepository repository;
private final YoutubeCredentialCipher cipher;
@Value("${youtube.oauth.client-id:}") private volatile String clientId;
@Value("${youtube.oauth.client-secret:}") private volatile String clientSecret;
@Value("${youtube.oauth.redirect-uri:http://localhost:8088/api/youtube/oauth/callback}") private volatile String redirectUri;
public YoutubeOAuthService(@Qualifier("restTemplate") RestTemplate restTemplate,
ObjectMapper objectMapper,
YoutubeConnectionRepository repository,
YoutubeCredentialCipher cipher) {
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
this.repository = repository;
this.cipher = cipher;
}
public URI authorizationUri(String state) {
requireConfiguration();
return UriComponentsBuilder.fromUriString(AUTH_URL)
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.queryParam("response_type", "code")
.queryParam("scope", SCOPE)
.queryParam("access_type", "offline")
.queryParam("include_granted_scopes", "true")
.queryParam("prompt", "consent")
.queryParam("state", state)
.build().encode().toUri();
}
public void configure(String clientId, String clientSecret, String redirectUri) {
this.clientId = clientId;
this.clientSecret = clientSecret;
if (redirectUri != null && !redirectUri.isBlank()) this.redirectUri = redirectUri;
}
public boolean isConfigured() {
return clientId != null && !clientId.isBlank() && clientSecret != null && !clientSecret.isBlank();
}
public String redirectUri() {
return redirectUri;
}
@Transactional
public YoutubeConnection connect(String code) {
requireConfiguration();
JsonNode token = exchangeCode(code);
String accessToken = requiredText(token, "access_token", "Google access token이 없습니다.");
String refreshToken = requiredText(token, "refresh_token", "Google refresh token이 없습니다. 권한 동의를 다시 진행해 주세요.");
JsonNode channel = loadChannel(accessToken);
String channelId = requiredText(channel, "id", "연결된 YouTube 채널 ID가 없습니다.");
String title = channel.path("snippet").path("title").asText("YouTube 채널");
String handle = channel.path("snippet").path("customUrl").asText("");
String thumbnail = channel.path("snippet").path("thumbnails").path("medium").path("url").asText("");
String encryptedToken = cipher.encrypt(refreshToken);
YoutubeConnection connection = repository.findByChannelId(channelId)
.orElseGet(() -> new YoutubeConnection(channelId, title, handle, thumbnail, encryptedToken));
connection.update(title, handle, thumbnail, encryptedToken);
return repository.save(connection);
}
@Transactional(readOnly = true)
public List<ConnectionDto> connections() {
return repository.findAllByOrderByIdAsc().stream().map(ConnectionDto::from).toList();
}
@Transactional
public void disconnect(Long id) {
repository.deleteById(id);
}
private JsonNode exchangeCode(String code) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("code", code);
form.add("client_id", clientId);
form.add("client_secret", clientSecret);
form.add("redirect_uri", redirectUri);
form.add("grant_type", "authorization_code");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
String body = restTemplate.postForObject(TOKEN_URL, new HttpEntity<>(form, headers), String.class);
return readJson(body);
}
private JsonNode loadChannel(String accessToken) {
URI uri = UriComponentsBuilder.fromUriString(CHANNELS_URL)
.queryParam("part", "snippet,contentDetails,statistics")
.queryParam("mine", "true").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();
JsonNode items = readJson(body).path("items");
if (!items.isArray() || items.isEmpty()) {
throw new IllegalStateException("이 Google 계정에서 YouTube 채널을 찾지 못했습니다.");
}
return items.get(0);
}
private JsonNode readJson(String body) {
try { return objectMapper.readTree(body); }
catch (Exception e) { throw new IllegalStateException("Google 응답을 해석하지 못했습니다.", e); }
}
private String requiredText(JsonNode node, String field, String message) {
String value = node.path(field).asText("");
if (value.isBlank()) throw new IllegalStateException(message);
return value;
}
private void requireConfiguration() {
if (!isConfigured()) {
throw new IllegalStateException("YOUTUBE_OAUTH_CLIENT_ID와 YOUTUBE_OAUTH_CLIENT_SECRET을 설정해 주세요.");
}
}
public record ConnectionDto(Long id, String channelId, String title, String handle,
String thumbnailUrl, String tokenStatus, java.time.LocalDateTime lastSyncedAt) {
static ConnectionDto from(YoutubeConnection c) {
return new ConnectionDto(c.getId(), c.getChannelId(), c.getChannelTitle(), c.getChannelHandle(),
c.getThumbnailUrl(), c.getTokenStatus(), c.getLastSyncedAt());
}
}
}

View File

@ -6,3 +6,8 @@ DB_URL: jdbc:postgresql://<host>:5432/<db>
DB_USERNAME: <username> DB_USERNAME: <username>
DB_PASSWORD: <password> DB_PASSWORD: <password>
YOUTUBE_API_KEY: <youtube-data-api-key> YOUTUBE_API_KEY: <youtube-data-api-key>
YOUTUBE_OAUTH_CLIENT_ID: <google-oauth-web-client-id>
YOUTUBE_OAUTH_CLIENT_SECRET: <google-oauth-web-client-secret>
YOUTUBE_OAUTH_REDIRECT_URI: http://localhost:8088/api/youtube/oauth/callback
# Generate once: openssl rand -base64 32
YOUTUBE_OAUTH_ENCRYPTION_KEY: <base64-encoded-32-byte-key>

View File

@ -50,6 +50,11 @@ server:
youtube: youtube:
api: api:
key: ${YOUTUBE_API_KEY:} key: ${YOUTUBE_API_KEY:}
oauth:
client-id: ${YOUTUBE_OAUTH_CLIENT_ID:}
client-secret: ${YOUTUBE_OAUTH_CLIENT_SECRET:}
redirect-uri: ${YOUTUBE_OAUTH_REDIRECT_URI:http://localhost:8088/api/youtube/oauth/callback}
encryption-key: ${YOUTUBE_OAUTH_ENCRYPTION_KEY:}
# Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드. # Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드.
python: python:

View File

@ -72,6 +72,15 @@
.us-submit { width:100%; min-height:46px; border:0; border-radius:var(--r-sm); font-weight:800; cursor:pointer; } .us-submit { width:100%; min-height:46px; border:0; border-radius:var(--r-sm); font-weight:800; cursor:pointer; }
.us-empty { padding:3rem 1rem; text-align:center; color:var(--text-2); } .us-empty { padding:3rem 1rem; text-align:center; color:var(--text-2); }
.us-toast { position:fixed; right:1.25rem; bottom:1.25rem; background:#201d35; color:white; padding:.85rem 1rem; border-radius:6px; box-shadow:var(--shadow-lg); z-index:20; display:none; } .us-toast { position:fixed; right:1.25rem; bottom:1.25rem; background:#201d35; color:white; padding:.85rem 1rem; border-radius:6px; box-shadow:var(--shadow-lg); z-index:20; display:none; }
.us-modal { position:fixed; inset:0; z-index:30; background:rgba(15,14,22,.58); display:none; place-items:center; padding:1rem; }
.us-modal.open { display:grid; }
.us-dialog { width:min(520px,100%); max-height:calc(100vh - 2rem); overflow:auto; background:var(--surface); border:1px solid var(--border-strong); box-shadow:var(--shadow-lg); }
.us-dialog-head { padding:1.1rem 1.2rem; border-bottom:1px solid var(--border); display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; }
.us-dialog-head h2 { margin:0 0 .3rem; font-size:1.3rem; }
.us-dialog-body { padding:1.2rem; display:grid; gap:1rem; }
.us-help { background:var(--studio-soft); border-left:3px solid var(--studio); padding:.75rem .85rem; color:var(--text-2); font-size:.78rem; line-height:1.55; }
.us-help code { color:var(--text); overflow-wrap:anywhere; }
.us-dialog-actions { display:flex; justify-content:flex-end; gap:.5rem; }
@media(max-width:1100px){ .us-work{grid-template-columns:1fr}.us-panel{position:static}.us-card{grid-template-columns:28px 100px minmax(150px,1fr) 110px}.us-time{display:none} } @media(max-width:1100px){ .us-work{grid-template-columns:1fr}.us-panel{position:static}.us-card{grid-template-columns:28px 100px minmax(150px,1fr) 110px}.us-time{display:none} }
@media(max-width:760px){ .us-head{align-items:flex-start;flex-direction:column}.us-top{grid-template-columns:1fr}.us-folder{border-left:0;border-top:1px solid var(--border)}.us-summary{grid-template-columns:1fr 1fr}.us-stat:nth-child(2){border-right:0}.us-stat:nth-child(-n+2){border-bottom:1px solid var(--border)}.us-card{grid-template-columns:24px 84px minmax(120px,1fr)}.us-badge{grid-column:3}.us-head .us-button{width:100%} } @media(max-width:760px){ .us-head{align-items:flex-start;flex-direction:column}.us-top{grid-template-columns:1fr}.us-folder{border-left:0;border-top:1px solid var(--border)}.us-summary{grid-template-columns:1fr 1fr}.us-stat:nth-child(2){border-right:0}.us-stat:nth-child(-n+2){border-bottom:1px solid var(--border)}.us-card{grid-template-columns:24px 84px minmax(120px,1fr)}.us-badge{grid-column:3}.us-head .us-button{width:100%} }
@media(prefers-reduced-motion:reduce){ *{scroll-behavior:auto!important;transition:none!important} } @media(prefers-reduced-motion:reduce){ *{scroll-behavior:auto!important;transition:none!important} }
@ -81,12 +90,12 @@
<div layout:fragment="content" class="us-shell"> <div layout:fragment="content" class="us-shell">
<header class="us-head"> <header class="us-head">
<div><div class="us-kicker">YouTube operations desk</div><h1>업로드 스튜디오</h1><p>로컬 내보내기 영상과 채널 발행 기록을 맞춰 보고, 업로드와 예약을 한 흐름에서 관리합니다.</p></div> <div><div class="us-kicker">YouTube operations desk</div><h1>업로드 스튜디오</h1><p>로컬 내보내기 영상과 채널 발행 기록을 맞춰 보고, 업로드와 예약을 한 흐름에서 관리합니다.</p></div>
<button class="us-button us-primary" type="button" onclick="syncStudio()"><i data-lucide="refresh-cw"></i> YouTube 동기화</button> <div style="display:flex;gap:.5rem;flex-wrap:wrap"><button class="us-button" type="button" onclick="connectYoutube()"><i data-lucide="link"></i> 채널 연결</button><button class="us-button us-primary" type="button" onclick="syncStudio()"><i data-lucide="refresh-cw"></i> YouTube 동기화</button></div>
</header> </header>
<section class="us-top" aria-label="채널과 폴더 현황"> <section class="us-top" aria-label="채널과 폴더 현황">
<div class="us-channel"> <div class="us-channel">
<div class="us-channel-line"><div class="us-avatar">H</div><div><div class="us-channel-name">h-lab Shorts</div><div class="us-muted">@hlab-shorts · 마지막 동기화 방금 전</div></div><select class="us-select" aria-label="YouTube 채널"><option>h-lab Shorts</option><option>h-lab Politics</option></select></div> <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-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 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>
@ -117,6 +126,19 @@
</form> </form>
</aside> </aside>
</div> </div>
<div class="us-modal" id="oauthModal" role="dialog" aria-modal="true" aria-labelledby="oauthTitle" onclick="if(event.target===this)closeOAuthSettings()">
<div class="us-dialog">
<div class="us-dialog-head"><div><div class="us-kicker">One-time setup</div><h2 id="oauthTitle">YouTube API 키 연결</h2><div class="us-muted">Google Cloud의 웹 OAuth 클라이언트 정보를 한 번만 입력하세요.</div></div><button class="us-button" type="button" aria-label="닫기" onclick="closeOAuthSettings()">×</button></div>
<form class="us-dialog-body" onsubmit="saveOAuthSettings(event)">
<div class="us-help">Google Cloud Console에서 <b>YouTube Data API v3</b>를 사용 설정하고 OAuth 클라이언트 유형을 <b>웹 애플리케이션</b>으로 만드세요. 승인된 리디렉션 URI에는 아래 콜백 주소를 그대로 등록해야 합니다.</div>
<div class="us-field"><label for="oauthClientId">Client ID</label><input id="oauthClientId" autocomplete="off" placeholder="000000000000-….apps.googleusercontent.com" required></div>
<div class="us-field"><label for="oauthClientSecret">Client Secret</label><input id="oauthClientSecret" type="password" autocomplete="new-password" placeholder="GOCSPX-…" required></div>
<div class="us-field"><label for="oauthRedirectUri">승인된 리디렉션 URI</label><input id="oauthRedirectUri" type="url" value="http://localhost:8088/api/youtube/oauth/callback" required></div>
<div class="us-help">키는 브라우저에 보관하지 않으며 Git에서 제외된 서버 로컬 설정에 저장됩니다. 보안을 위해 이 화면은 서버 컴퓨터에서 접속했을 때만 저장할 수 있습니다.</div>
<div class="us-dialog-actions"><button class="us-button" type="button" onclick="closeOAuthSettings()">취소</button><button class="us-button us-primary" id="oauthSaveButton" type="submit">저장하고 채널 연결</button></div>
</form>
</div>
</div>
<div class="us-toast" id="studioToast" role="status"></div> <div class="us-toast" id="studioToast" role="status"></div>
</div> </div>
@ -147,10 +169,48 @@
document.getElementById('folderFallback').click(); document.getElementById('folderFallback').click();
} }
document.getElementById('folderFallback').addEventListener('change',e=>{const files=[...e.target.files].filter(f=>f.type.startsWith('video/'));videos=files.map((f,i)=>({id:Date.now()+i,name:f.name,meta:(f.size/1048576).toFixed(1)+' MB · 로컬 영상',status:'wait',label:'업로드 대기'}));document.getElementById('folderTitle').textContent=e.target.files[0]?.webkitRelativePath.split('/')[0]||'선택한 폴더';document.getElementById('folderPath').textContent='권한 연결됨 · '+videos.length+'개 영상 발견';renderVideos();}); document.getElementById('folderFallback').addEventListener('change',e=>{const files=[...e.target.files].filter(f=>f.type.startsWith('video/'));videos=files.map((f,i)=>({id:Date.now()+i,name:f.name,meta:(f.size/1048576).toFixed(1)+' MB · 로컬 영상',status:'wait',label:'업로드 대기'}));document.getElementById('folderTitle').textContent=e.target.files[0]?.webkitRelativePath.split('/')[0]||'선택한 폴더';document.getElementById('folderPath').textContent='권한 연결됨 · '+videos.length+'개 영상 발견';renderVideos();});
function syncStudio(){showToast('YouTube 채널 기록을 동기화했습니다.');} let connections=[];
let oauthConfigured=false;
async function loadOAuthStatus(){
try{const response=await fetch('/api/youtube/settings/status');const body=await response.json();oauthConfigured=!!body.data.configured;if(body.data.redirectUri)document.getElementById('oauthRedirectUri').value=body.data.redirectUri;}catch(e){oauthConfigured=false;}
}
async function connectYoutube(){await loadOAuthStatus();if(!oauthConfigured){openOAuthSettings();return;}location.href='/api/youtube/oauth/start';}
function openOAuthSettings(){document.getElementById('oauthModal').classList.add('open');setTimeout(()=>document.getElementById('oauthClientId').focus(),0);}
function closeOAuthSettings(){document.getElementById('oauthModal').classList.remove('open');}
async function saveOAuthSettings(event){
event.preventDefault();
const button=document.getElementById('oauthSaveButton');button.disabled=true;button.textContent='안전하게 저장 중…';
try{
const response=await fetch('/api/youtube/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientId:document.getElementById('oauthClientId').value.trim(),clientSecret:document.getElementById('oauthClientSecret').value.trim(),redirectUri:document.getElementById('oauthRedirectUri').value.trim()})});
const body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'설정 저장 실패');
document.getElementById('oauthClientSecret').value='';oauthConfigured=true;location.href='/api/youtube/oauth/start';
}catch(e){showToast(e.message||'키 설정을 저장하지 못했습니다.');button.disabled=false;button.textContent='저장하고 채널 연결';}
}
async function loadConnections(){
try{
const response=await fetch('/api/youtube/connections');
const body=await response.json();
if(!body.success) throw new Error(body.message);
connections=body.data||[];
const select=document.getElementById('channelSelect');
select.innerHTML=connections.length?connections.map(c=>`<option value="${c.id}">${esc(c.title)}</option>`).join(''):'<option value="">연결된 채널 없음</option>';
selectChannel(select.value);
}catch(e){showToast('채널 정보를 불러오지 못했습니다.');}
}
function selectChannel(id){
const c=connections.find(x=>String(x.id)===String(id));
document.getElementById('channelName').textContent=c?c.title:'YouTube 채널을 연결하세요';
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 채널 정보를 동기화했습니다.'));}
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 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);} 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(); renderVideos();
loadOAuthStatus();
loadConnections();
</script> </script>
</th:block> </th:block>
</body> </body>