Compare commits

..

2 Commits

10 changed files with 444 additions and 38 deletions

View File

@ -13,8 +13,11 @@ 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 org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.net.URI;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@ -25,6 +28,7 @@ public class YoutubeOAuthController {
private static final String STATE_SESSION_KEY = "youtube.oauth.state";
private final YoutubeOAuthService service;
private final YoutubeOAuthLocalSettings localSettings;
private final YoutubeUploadService uploadService;
@GetMapping("/oauth/start")
public ResponseEntity<Void> start(HttpSession session) {
@ -87,6 +91,23 @@ public class YoutubeOAuthController {
return ApiResponse.ok(service.sync(id));
}
@GetMapping("/connections/{id}/uploads")
@ResponseBody
public ApiResponse<List<YoutubeUploadService.JobDto>> uploads(@PathVariable Long id) {
return ApiResponse.ok(uploadService.jobs(id));
}
@PostMapping(value = "/connections/{id}/uploads", consumes = "multipart/form-data")
@ResponseBody
public ApiResponse<YoutubeUploadService.JobDto> upload(@PathVariable Long id,
@RequestPart("file") MultipartFile file,
@RequestParam String title,
@RequestParam(defaultValue = "") String description,
@RequestParam(defaultValue = "public") String privacyStatus,
@RequestParam(required = false) Instant publishAt) {
return ApiResponse.created(uploadService.upload(id, file, title, description, privacyStatus, publishAt));
}
private ResponseEntity<Void> redirect(String location) {
return ResponseEntity.status(302).location(URI.create(location)).build();
}

View File

@ -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
@ -145,6 +156,12 @@ public class YoutubeOAuthService {
repository.deleteById(id);
}
public String accessToken(Long connectionId) {
YoutubeConnection connection = repository.findById(connectionId)
.orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾지 못했습니다."));
return refreshAccessToken(cipher.decrypt(connection.getEncryptedRefreshToken()));
}
private JsonNode exchangeCode(String code) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("code", code);
@ -182,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")
@ -221,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) { }
}

View File

@ -0,0 +1,68 @@
package com.hlab.yanalyst.domain.upload;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
@Component
public class YoutubeResumableUploadClient {
private static final String UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos";
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
public YoutubeResumableUploadClient(@Qualifier("restTemplate") RestTemplate restTemplate, ObjectMapper objectMapper) {
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
public String upload(String accessToken, Path file, long size, String contentType, String title,
String description, String privacyStatus, Instant publishAt) throws Exception {
ObjectNode metadata = objectMapper.createObjectNode();
metadata.putObject("snippet").put("title", title).put("description", description).put("categoryId", "22");
ObjectNode status = metadata.putObject("status");
status.put("privacyStatus", publishAt == null ? privacyStatus : "private");
status.put("selfDeclaredMadeForKids", false);
if (publishAt != null) status.put("publishAt", publishAt.toString());
URI initiateUri = UriComponentsBuilder.fromUriString(UPLOAD_URL)
.queryParam("uploadType", "resumable").queryParam("part", "snippet,status").build().toUri();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Upload-Content-Type", contentType);
headers.set("X-Upload-Content-Length", Long.toString(size));
ResponseEntity<String> initiated = restTemplate.exchange(initiateUri, HttpMethod.POST,
new HttpEntity<>(objectMapper.writeValueAsString(metadata), headers), String.class);
URI sessionUri = initiated.getHeaders().getLocation();
if (sessionUri == null) throw new IllegalStateException("YouTube 업로드 세션 주소를 받지 못했습니다.");
HttpRequest request = HttpRequest.newBuilder(sessionUri).timeout(Duration.ofHours(2))
.header("Authorization", "Bearer " + accessToken)
.header("Content-Type", contentType)
.header("Content-Length", Long.toString(size))
.PUT(HttpRequest.BodyPublishers.ofFile(file)).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IllegalStateException("YouTube 업로드 실패 (HTTP " + response.statusCode() + "): "
+ response.body().substring(0, Math.min(500, response.body().length())));
}
JsonNode result = objectMapper.readTree(response.body());
String videoId = result.path("id").asText("");
if (videoId.isBlank()) throw new IllegalStateException("YouTube 영상 ID를 받지 못했습니다.");
return videoId;
}
}

View File

@ -0,0 +1,59 @@
package com.hlab.yanalyst.domain.upload;
import jakarta.persistence.*;
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 java.time.Instant;
import java.time.LocalDateTime;
@Entity
@Table(name = "youtube_upload_jobs")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class YoutubeUploadJob {
public enum Status { UPLOADING, SCHEDULED, PUBLISHED, FAILED }
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false) private Long connectionId;
@Column(nullable = false) private String fileName;
@Column(nullable = false) private String title;
@Column(columnDefinition = "TEXT") private String description;
@Column(nullable = false, length = 20) private String privacyStatus;
private Instant publishAt;
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private Status status;
private String youtubeVideoId;
@Column(columnDefinition = "TEXT") private String errorMessage;
private long fileSize;
@CreatedDate @Column(updatable = false) private LocalDateTime createdAt;
@LastModifiedDate private LocalDateTime updatedAt;
public YoutubeUploadJob(Long connectionId, String fileName, String title, String description,
String privacyStatus, Instant publishAt, long fileSize) {
this.connectionId = connectionId;
this.fileName = fileName;
this.title = title;
this.description = description;
this.privacyStatus = privacyStatus;
this.publishAt = publishAt;
this.fileSize = fileSize;
this.status = Status.UPLOADING;
}
public void complete(String videoId) {
this.youtubeVideoId = videoId;
this.status = publishAt == null ? Status.PUBLISHED : Status.SCHEDULED;
this.errorMessage = null;
}
public void fail(String message) {
this.status = Status.FAILED;
this.errorMessage = message == null ? "YouTube 업로드에 실패했습니다." : message;
}
}

View File

@ -0,0 +1,8 @@
package com.hlab.yanalyst.domain.upload;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface YoutubeUploadJobRepository extends JpaRepository<YoutubeUploadJob, Long> {
List<YoutubeUploadJob> findAllByConnectionIdOrderByCreatedAtDesc(Long connectionId);
}

View File

@ -0,0 +1,67 @@
package com.hlab.yanalyst.domain.upload;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
@Service
public class YoutubeUploadService {
private final YoutubeOAuthService oauthService;
private final YoutubeUploadJobRepository jobRepository;
private final YoutubeResumableUploadClient uploadClient;
public YoutubeUploadService(YoutubeOAuthService oauthService, YoutubeUploadJobRepository jobRepository,
YoutubeResumableUploadClient uploadClient) {
this.oauthService = oauthService;
this.jobRepository = jobRepository;
this.uploadClient = uploadClient;
}
public JobDto upload(Long connectionId, MultipartFile file, String title, String description,
String privacyStatus, Instant publishAt) {
if (file == null || file.isEmpty()) throw new IllegalArgumentException("업로드할 영상 파일을 선택해 주세요.");
if (title == null || title.isBlank()) throw new IllegalArgumentException("영상 제목을 입력해 주세요.");
if (title.length() > 100) throw new IllegalArgumentException("영상 제목은 100자 이하여야 합니다.");
if (!List.of("public", "unlisted", "private").contains(privacyStatus)) throw new IllegalArgumentException("공개 상태가 올바르지 않습니다.");
if (publishAt != null && !publishAt.isAfter(Instant.now())) throw new IllegalArgumentException("예약 시간은 현재보다 이후여야 합니다.");
YoutubeUploadJob job = jobRepository.saveAndFlush(new YoutubeUploadJob(connectionId,
file.getOriginalFilename() == null ? "video" : file.getOriginalFilename(), title,
description == null ? "" : description, publishAt == null ? privacyStatus : "private", publishAt, file.getSize()));
Path temporaryFile = null;
try {
String suffix = file.getOriginalFilename() != null && file.getOriginalFilename().contains(".")
? file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf('.')) : ".video";
temporaryFile = Files.createTempFile("hlab-youtube-", suffix);
file.transferTo(temporaryFile);
String contentType = file.getContentType() == null ? "application/octet-stream" : file.getContentType();
String videoId = uploadClient.upload(oauthService.accessToken(connectionId), temporaryFile,
file.getSize(), contentType, title.trim(), description == null ? "" : description, privacyStatus, publishAt);
job.complete(videoId);
return JobDto.from(jobRepository.save(job));
} catch (Exception e) {
job.fail(e.getMessage());
jobRepository.save(job);
throw new IllegalStateException(e.getMessage(), e);
} finally {
if (temporaryFile != null) try { Files.deleteIfExists(temporaryFile); } catch (Exception ignored) { }
}
}
public List<JobDto> jobs(Long connectionId) {
return jobRepository.findAllByConnectionIdOrderByCreatedAtDesc(connectionId).stream().map(JobDto::from).toList();
}
public record JobDto(Long id, String fileName, String title, String privacyStatus, Instant publishAt,
String status, String youtubeVideoId, String errorMessage, long fileSize,
java.time.LocalDateTime createdAt) {
static JobDto from(YoutubeUploadJob job) {
return new JobDto(job.getId(), job.getFileName(), job.getTitle(), job.getPrivacyStatus(), job.getPublishAt(),
job.getStatus().name(), job.getYoutubeVideoId(), job.getErrorMessage(), job.getFileSize(), job.getCreatedAt());
}
}
}

View File

@ -27,6 +27,13 @@ public class GlobalExceptionHandler {
.body(ApiResponse.error(e.getMessage()));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalState(IllegalStateException e) {
log.error("IllegalStateException: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(ApiResponse.error(e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationExceptions(MethodArgumentNotValidException e) {
String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();

View File

@ -7,8 +7,8 @@ spring:
import: optional:classpath:application-local.yml
servlet:
multipart:
max-file-size: ${UPLOAD_MAX_FILE_SIZE:200MB} # 업로드 영상(Whisper 전사용)
max-request-size: ${UPLOAD_MAX_REQUEST_SIZE:200MB}
max-file-size: ${UPLOAD_MAX_FILE_SIZE:2GB} # YouTube 업로드 및 Whisper 전사용
max-request-size: ${UPLOAD_MAX_REQUEST_SIZE:2GB}
datasource:
url: ${DB_URL:}
driverClassName: org.postgresql.Driver

View File

@ -20,14 +20,18 @@
.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; }
.us-button { min-height:42px; border:1px solid var(--border-strong); background:var(--surface); color:var(--text); padding:.6rem .85rem; border-radius:var(--r-sm); font-weight:700; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:.45rem; }
.us-button svg { width:16px; height:16px; }
.us-button:disabled { cursor:not-allowed; opacity:.45; }
.us-danger { color:var(--danger); border-color:rgba(var(--danger-rgb),.35); }
.us-summary { display:grid; grid-template-columns:repeat(4,1fr); border:1px solid var(--border); background:var(--surface); margin-bottom:1rem; }
.us-stat { padding:.85rem 1rem; border-right:1px solid var(--border); }
.us-stat:last-child { border:0; }
@ -58,6 +62,9 @@
.us-badge.wait { color:#6655e8; background:var(--studio-soft); }
.us-badge.done { color:var(--success); background:rgba(var(--success-rgb),.1); }
.us-badge.review { color:var(--warning); background:rgba(196,122,18,.11); }
.us-badge.scheduled { color:#6655e8; background:var(--studio-soft); }
.us-badge.uploading { color:var(--warning); background:rgba(196,122,18,.11); }
.us-badge.failed { color:var(--danger); background:rgba(var(--danger-rgb),.1); }
.us-time { color:var(--text-2); font-size:.75rem; }
.us-panel { position:sticky; top:1rem; border:1px solid var(--border); background:var(--surface); }
.us-panel-head { padding:1rem; border-bottom:1px solid var(--border); }
@ -90,7 +97,7 @@
<div layout:fragment="content" class="us-shell">
<header class="us-head">
<div><div class="us-kicker">YouTube operations desk</div><h1>업로드 스튜디오</h1><p>로컬 내보내기 영상과 채널 발행 기록을 맞춰 보고, 업로드와 예약을 한 흐름에서 관리합니다.</p></div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap"><button class="us-button" type="button" onclick="openOAuthSettings()"><i data-lucide="settings"></i> OAuth 설정</button><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>
<div style="display:flex;gap:.5rem;flex-wrap:wrap"><button class="us-button" type="button" onclick="openOAuthSettings()"><i data-lucide="settings"></i> OAuth 설정</button><button class="us-button" type="button" onclick="connectYoutube()"><i data-lucide="link"></i> 채널 연결</button><button class="us-button us-danger" id="disconnectButton" type="button" onclick="disconnectYoutube()" disabled><i data-lucide="unlink"></i> 연결 해제</button><button class="us-button us-primary" type="button" onclick="syncStudio()"><i data-lucide="refresh-cw"></i> YouTube 동기화</button></div>
</header>
<section class="us-top" aria-label="채널과 폴더 현황">
@ -102,22 +109,22 @@
</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 id="recentCount">—</strong><span>최근 7일 발행</span></div><div class="us-stat"><strong>오늘 18:30</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">
<section class="us-library">
<div class="us-toolbar"><div class="us-search"><i data-lucide="search"></i><input id="videoSearch" type="search" placeholder="파일명으로 검색" oninput="renderVideos()"></div><button class="us-button" type="button" onclick="sortVideos()"><i data-lucide="arrow-up-down"></i> 최신순</button></div>
<div class="us-tabs" role="tablist"><button class="us-tab active" data-filter="all" onclick="setFilter(this)">전체</button><button class="us-tab" data-filter="wait" onclick="setFilter(this)">업로드 대기</button><button class="us-tab" data-filter="review" onclick="setFilter(this)">확인 필요</button><button class="us-tab" data-filter="done" onclick="setFilter(this)">업로드 완료</button></div>
<div class="us-tabs" role="tablist"><button class="us-tab active" data-filter="all" onclick="setFilter(this)">전체</button><button class="us-tab" data-filter="wait" onclick="setFilter(this)">업로드 대기</button><button class="us-tab" data-filter="scheduled" onclick="setFilter(this)">예약</button><button class="us-tab" data-filter="done" onclick="setFilter(this)">업로드 완료</button><button class="us-tab" data-filter="failed" onclick="setFilter(this)">실패</button></div>
<div class="us-list" id="videoList"></div>
</section>
<aside class="us-panel" aria-label="업로드 설정">
<div class="us-panel-head"><div class="us-kicker">Selected video</div><h2 id="panelTitle">영상 1개 선택됨</h2><div class="us-muted">업로드 전 정보를 확인하세요.</div></div>
<form class="us-form" onsubmit="submitUpload(event)">
<div class="us-field"><label for="uploadTitle">제목</label><input id="uploadTitle" value="오늘 꼭 알아야 할 이슈 3가지" maxlength="100"></div>
<div class="us-field"><label for="uploadDescription">설명</label><textarea id="uploadDescription">핵심만 빠르게 정리했습니다.\n\n#shorts #이슈</textarea></div>
<div class="us-field"><label for="privacy">공개 상태</label><select id="privacy"><option>공개</option><option>일부 공개</option><option>비공개</option></select></div>
<div class="us-field"><label for="uploadTitle">제목</label><input id="uploadTitle" maxlength="100"></div>
<div class="us-field"><label for="uploadDescription">설명</label><textarea id="uploadDescription"></textarea></div>
<div class="us-field"><label for="privacy">공개 상태</label><select id="privacy"><option value="public">공개</option><option value="unlisted">일부 공개</option><option value="private">비공개</option></select></div>
<div class="us-field"><label>업로드 시점</label><div class="us-mode"><button class="active" type="button" onclick="setMode(this,'now')">즉시 업로드</button><button type="button" onclick="setMode(this,'schedule')">예약</button></div></div>
<div class="us-field" id="scheduleField" hidden><label for="scheduleAt">공개 예정 시간 · Asia/Seoul</label><input id="scheduleAt" type="datetime-local"></div>
<button class="us-submit us-primary" id="submitButton" type="submit">YouTube에 업로드</button>
@ -143,30 +150,36 @@
<th:block layout:fragment="script">
<script>
let filter='all', uploadMode='now', descending=true;
let videos=[
{id:1,name:'오늘 꼭 알아야 할 이슈 3가지.mp4',meta:'1080×1920 · 42초 · 86.4 MB',status:'wait',label:'업로드 대기'},
{id:2,name:'출근길 뉴스 한입_0831.mp4',meta:'1080×1920 · 57초 · 104.8 MB',status:'review',label:'확인 필요'},
{id:3,name:'인터뷰 핵심 장면.mov',meta:'2160×3840 · 38초 · 231.2 MB',status:'wait',label:'업로드 대기'},
{id:4,name:'지난주 베스트 클립.mp4',meta:'1080×1920 · 51초 · 97.1 MB',status:'done',label:'업로드 완료'}
];
let selectedId=1;
let localVideos=[], uploadJobs=[], recentUploads=[], videos=[];
let selectedId=null;
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function renderVideos(){
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==='review'?'!':'✓'} ${v.label}</span><span class="us-time">오늘 · ${v.id+9}:2${v.id}</span></button>`).join(''):'<div class="us-empty">조건에 맞는 영상이 없습니다.</div>';
document.getElementById('totalCount').textContent=videos.length;
document.getElementById('waitCount').textContent=videos.filter(v=>v.status==='wait').length;
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>';
}
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;
renderVideos();
}
function selectVideo(id){
selectedId=id;const v=videos.find(x=>x.id===id);if(!v)return;
document.getElementById('panelTitle').textContent=v.file?'영상 1개 선택됨':'업로드 기록';
document.getElementById('uploadTitle').value=v.title||v.name.replace(/\.[^.]+$/,'').replaceAll('_',' ');
document.getElementById('submitButton').disabled=!v.file;
renderVideos();
}
function selectVideo(id){selectedId=id;const v=videos.find(x=>x.id===id);document.getElementById('panelTitle').textContent='영상 1개 선택됨';document.getElementById('uploadTitle').value=v.name.replace(/\.[^.]+$/,'').replaceAll('_',' ');renderVideos();}
function setFilter(el){filter=el.dataset.filter;document.querySelectorAll('.us-tab').forEach(x=>x.classList.toggle('active',x===el));renderVideos();}
function sortVideos(){descending=!descending;videos.reverse();renderVideos();}
function setMode(el,mode){uploadMode=mode;document.querySelectorAll('.us-mode button').forEach(x=>x.classList.toggle('active',x===el));document.getElementById('scheduleField').hidden=mode!=='schedule';document.getElementById('submitButton').textContent=mode==='schedule'?'예약 업로드 등록':'YouTube에 업로드';}
async function chooseFolder(){
if(window.showDirectoryPicker){try{const dir=await window.showDirectoryPicker();const found=[];for await(const entry of dir.values()){if(entry.kind==='file'&&/\.(mp4|mov|m4v|webm)$/i.test(entry.name))found.push({id:Date.now()+found.length,name:entry.name,meta:'로컬 영상 · 분석 대기',status:'wait',label:'업로드 대기'});}videos=found;document.getElementById('folderTitle').textContent=dir.name;document.getElementById('folderPath').textContent='권한 연결됨 · '+found.length+'개 영상 발견';selectedId=found[0]?.id||null;renderVideos();showToast(found.length+'개 영상을 불러왔습니다.');return;}catch(e){if(e.name==='AbortError')return;}}
if(window.showDirectoryPicker){try{const dir=await window.showDirectoryPicker();const found=[];for await(const entry of dir.values()){if(entry.kind==='file'&&/\.(mp4|mov|m4v|webm)$/i.test(entry.name)){const file=await entry.getFile();found.push(localVideo(file,found.length));}}localVideos=found;document.getElementById('folderTitle').textContent=dir.name;document.getElementById('folderPath').textContent='권한 연결됨 · '+found.length+'개 영상 발견';selectedId=found[0]?.id||null;rebuildVideos();if(selectedId)selectVideo(selectedId);showToast(found.length+'개 영상을 불러왔습니다.');return;}catch(e){if(e.name==='AbortError')return;}}
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();});
function localVideo(file,index){return{id:'local-'+Date.now()+'-'+index,name:file.name,title:file.name.replace(/\.[^.]+$/,'').replaceAll('_',' '),meta:(file.size/1048576).toFixed(1)+' MB · 로컬 영상',status:'wait',label:'업로드 대기',file,time:'로컬'};}
document.getElementById('folderFallback').addEventListener('change',e=>{const files=[...e.target.files].filter(f=>f.type.startsWith('video/'));localVideos=files.map(localVideo);document.getElementById('folderTitle').textContent=e.target.files[0]?.webkitRelativePath.split('/')[0]||'선택한 폴더';document.getElementById('folderPath').textContent='권한 연결됨 · '+localVideos.length+'개 영상 발견';selectedId=localVideos[0]?.id||null;rebuildVideos();if(selectedId)selectVideo(selectedId);});
let connections=[];
let oauthConfigured=false;
async function loadOAuthStatus(){
@ -200,6 +213,34 @@
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';
document.getElementById('disconnectButton').disabled=!c;
if(c)loadUploadJobs(c.id);else{uploadJobs=[];recentUploads=[];rebuildVideos();}
}
async function loadUploadJobs(connectionId){
try{
const response=await fetch('/api/youtube/connections/'+connectionId+'/uploads');const body=await response.json();
if(!response.ok||!body.success)throw new Error(body.message||'업로드 기록 조회 실패');
uploadJobs=(body.data||[]).map(j=>({id:'job-'+j.id,name:j.fileName,title:j.title,
meta:(j.fileSize/1048576).toFixed(1)+' MB · '+(j.youtubeVideoId||'YouTube 처리 중'),
status:j.status==='SCHEDULED'?'scheduled':j.status==='PUBLISHED'?'done':j.status==='FAILED'?'failed':'uploading',
label:j.status==='SCHEDULED'?'예약됨':j.status==='PUBLISHED'?'업로드 완료':j.status==='FAILED'?'업로드 실패':'업로드 중',
youtubeVideoId:j.youtubeVideoId,publishAt:j.publishAt,time:j.publishAt?new Date(j.publishAt).toLocaleString('ko-KR'):'기록'}));
rebuildVideos();
}catch(e){showToast(e.message||'업로드 기록을 불러오지 못했습니다.');}
}
async function disconnectYoutube(){
const id=document.getElementById('channelSelect').value;
const connection=connections.find(c=>String(c.id)===String(id));
if(!connection)return;
if(!confirm(`'${connection.title}' 채널 연결을 해제할까요?\n저장된 OAuth 토큰만 삭제되며 영상 파일과 작업 데이터는 유지됩니다.`))return;
const button=document.getElementById('disconnectButton');button.disabled=true;
try{
const response=await fetch('/api/youtube/connections/'+id,{method:'DELETE'});
const body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'연결 해제 실패');
document.getElementById('recentCount').textContent='—';
await loadConnections();
showToast('YouTube 채널 연결을 해제했습니다.');
}catch(e){showToast(e.message||'채널 연결을 해제하지 못했습니다.');selectChannel(id);}
}
async function syncStudio(silent=false){
if(!connections.length){if(!silent)connectYoutube();return;}
@ -208,18 +249,45 @@
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+'개를 동기화했습니다.');
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:'조회수 '+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;
const v=videos.find(x=>x.id===selectedId);
if(!connectionId){showToast('먼저 YouTube 채널을 연결해 주세요.');return;}
if(!v||!v.file){showToast('폴더에서 업로드할 로컬 영상을 선택해 주세요.');return;}
const scheduleValue=document.getElementById('scheduleAt').value;
if(uploadMode==='schedule'&&!scheduleValue){showToast('공개 예정 시간을 선택해 주세요.');return;}
const form=new FormData();form.append('file',v.file);form.append('title',document.getElementById('uploadTitle').value.trim());
form.append('description',document.getElementById('uploadDescription').value);form.append('privacyStatus',document.getElementById('privacy').value);
if(uploadMode==='schedule')form.append('publishAt',new Date(scheduleValue).toISOString());
const button=document.getElementById('submitButton');button.disabled=true;button.textContent='YouTube로 업로드 중…';
v.status='uploading';v.label='업로드 중';renderVideos();
try{
const response=await fetch('/api/youtube/connections/'+connectionId+'/uploads',{method:'POST',body:form});
const body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'업로드 실패');
localVideos=localVideos.filter(x=>x.id!==v.id);selectedId=null;await loadUploadJobs(connectionId);
showToast(uploadMode==='schedule'?'YouTube 예약 공개를 등록했습니다.':'YouTube 업로드를 완료했습니다.');
}catch(err){v.status='failed';v.label='업로드 실패';renderVideos();showToast(err.message||'YouTube 업로드에 실패했습니다.');}
finally{button.disabled=false;button.textContent=uploadMode==='schedule'?'예약 업로드 등록':'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 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();
rebuildVideos();
loadOAuthStatus();
loadConnections().then(()=>{if(connections.length)syncStudio(true);});
</script>

View File

@ -0,0 +1,51 @@
package com.hlab.yanalyst.domain.upload;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class YoutubeUploadServiceTest {
private final YoutubeOAuthService oauthService = mock(YoutubeOAuthService.class);
private final YoutubeUploadJobRepository repository = mock(YoutubeUploadJobRepository.class);
private final YoutubeResumableUploadClient client = mock(YoutubeResumableUploadClient.class);
private final YoutubeUploadService service = new YoutubeUploadService(oauthService, repository, client);
@Test
void rejectsPastSchedule() {
MockMultipartFile file = new MockMultipartFile("file", "clip.mp4", "video/mp4", new byte[]{1});
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> service.upload(1L, file, "제목", "", "public", Instant.now().minusSeconds(10)));
assertTrue(error.getMessage().contains("예약 시간"));
verifyNoInteractions(repository, client);
}
@Test
void uploadsFileAndStoresYoutubeId() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "clip.mp4", "video/mp4", new byte[]{1, 2, 3});
when(repository.saveAndFlush(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(oauthService.accessToken(7L)).thenReturn("access-token");
when(client.upload(eq("access-token"), any(), eq(3L), eq("video/mp4"), eq("영상 제목"),
eq("설명"), eq("unlisted"), isNull())).thenReturn("youtube-id");
YoutubeUploadService.JobDto result = service.upload(7L, file, "영상 제목", "설명", "unlisted", null);
assertEquals("PUBLISHED", result.status());
assertEquals("youtube-id", result.youtubeVideoId());
verify(repository).save(any(YoutubeUploadJob.class));
}
@Test
void returnsStoredJobsForChannel() {
when(repository.findAllByConnectionIdOrderByCreatedAtDesc(3L)).thenReturn(List.of());
assertTrue(service.jobs(3L).isEmpty());
}
}