feat: 유튜브 실제 업로드와 예약 공개 구현
This commit is contained in:
parent
536d87aba2
commit
17b8a0c74d
@ -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();
|
||||
}
|
||||
|
||||
@ -145,6 +145,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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -28,6 +28,8 @@
|
||||
.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 +60,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 +95,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 +107,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="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>
|
||||
</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 +148,40 @@
|
||||
<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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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('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 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 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){
|
||||
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 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 +215,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 +251,41 @@
|
||||
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+'개를 동기화했습니다.');
|
||||
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,
|
||||
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('');
|
||||
}
|
||||
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'?'예약 업로드를 등록했습니다.':'업로드를 시작했습니다.');}
|
||||
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 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>
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user