Compare commits
2 Commits
536d87aba2
...
6151228248
| Author | SHA1 | Date | |
|---|---|---|---|
| 6151228248 | |||
| 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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.ResponseBody;
|
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.net.URI;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -25,6 +28,7 @@ public class YoutubeOAuthController {
|
|||||||
private static final String STATE_SESSION_KEY = "youtube.oauth.state";
|
private static final String STATE_SESSION_KEY = "youtube.oauth.state";
|
||||||
private final YoutubeOAuthService service;
|
private final YoutubeOAuthService service;
|
||||||
private final YoutubeOAuthLocalSettings localSettings;
|
private final YoutubeOAuthLocalSettings localSettings;
|
||||||
|
private final YoutubeUploadService uploadService;
|
||||||
|
|
||||||
@GetMapping("/oauth/start")
|
@GetMapping("/oauth/start")
|
||||||
public ResponseEntity<Void> start(HttpSession session) {
|
public ResponseEntity<Void> start(HttpSession session) {
|
||||||
@ -87,6 +91,23 @@ public class YoutubeOAuthController {
|
|||||||
return ApiResponse.ok(service.sync(id));
|
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) {
|
private ResponseEntity<Void> redirect(String location) {
|
||||||
return ResponseEntity.status(302).location(URI.create(location)).build();
|
return ResponseEntity.status(302).location(URI.create(location)).build();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.util.LinkedMultiValueMap;
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
import org.springframework.web.client.HttpStatusCodeException;
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
import java.net.URI;
|
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 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 TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||||
private static final String CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels";
|
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")
|
@Qualifier("restTemplate")
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
@ -121,7 +122,9 @@ public class YoutubeOAuthService {
|
|||||||
LocalDate today = LocalDate.now(zone);
|
LocalDate today = LocalDate.now(zone);
|
||||||
Map<LocalDate, Integer> counts = new LinkedHashMap<>();
|
Map<LocalDate, Integer> counts = new LinkedHashMap<>();
|
||||||
for (int i = 6; i >= 0; i--) counts.put(today.minusDays(i), 0);
|
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<RecentUploadDto> recent = new ArrayList<>();
|
||||||
|
List<String> recentVideoIds = new ArrayList<>();
|
||||||
for (JsonNode item : items) {
|
for (JsonNode item : items) {
|
||||||
String publishedText = item.path("contentDetails").path("videoPublishedAt").asText(
|
String publishedText = item.path("contentDetails").path("videoPublishedAt").asText(
|
||||||
item.path("snippet").path("publishedAt").asText(""));
|
item.path("snippet").path("publishedAt").asText(""));
|
||||||
@ -130,14 +133,22 @@ public class YoutubeOAuthService {
|
|||||||
LocalDate date = publishedAt.atZone(zone).toLocalDate();
|
LocalDate date = publishedAt.atZone(zone).toLocalDate();
|
||||||
if (counts.containsKey(date)) {
|
if (counts.containsKey(date)) {
|
||||||
counts.put(date, counts.get(date) + 1);
|
counts.put(date, counts.get(date) + 1);
|
||||||
recent.add(new RecentUploadDto(item.path("contentDetails").path("videoId").asText(""),
|
String videoId = item.path("contentDetails").path("videoId").asText("");
|
||||||
item.path("snippet").path("title").asText(""), publishedAt));
|
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();
|
connection.markSynced();
|
||||||
int total = counts.values().stream().mapToInt(Integer::intValue).sum();
|
int totalUploads = counts.values().stream().mapToInt(Integer::intValue).sum();
|
||||||
return new SyncDto(total, counts.entrySet().stream()
|
long totalViews = dailyViews.values().stream().mapToLong(Long::longValue).sum();
|
||||||
.map(e -> new DailyCountDto(e.getKey(), e.getValue())).toList(), recent);
|
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
|
@Transactional
|
||||||
@ -145,6 +156,12 @@ public class YoutubeOAuthService {
|
|||||||
repository.deleteById(id);
|
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) {
|
private JsonNode exchangeCode(String code) {
|
||||||
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
||||||
form.add("code", code);
|
form.add("code", code);
|
||||||
@ -182,6 +199,45 @@ public class YoutubeOAuthService {
|
|||||||
return readJson(body).path("items");
|
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) {
|
private JsonNode loadChannel(String accessToken) {
|
||||||
URI uri = UriComponentsBuilder.fromUriString(CHANNELS_URL)
|
URI uri = UriComponentsBuilder.fromUriString(CHANNELS_URL)
|
||||||
.queryParam("part", "snippet,contentDetails,statistics")
|
.queryParam("part", "snippet,contentDetails,statistics")
|
||||||
@ -221,8 +277,9 @@ public class YoutubeOAuthService {
|
|||||||
c.getThumbnailUrl(), c.getTokenStatus(), c.getLastSyncedAt());
|
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) { }
|
List<RecentUploadDto> recentUploads) { }
|
||||||
public record DailyCountDto(LocalDate date, int count) { }
|
public record DailyCountDto(LocalDate date, int count, long views) { }
|
||||||
public record RecentUploadDto(String videoId, String title, Instant publishedAt) { }
|
public record RecentUploadDto(String videoId, String title, Instant publishedAt, long viewCount) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()));
|
.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)
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleValidationExceptions(MethodArgumentNotValidException e) {
|
public ResponseEntity<ApiResponse<Void>> handleValidationExceptions(MethodArgumentNotValidException e) {
|
||||||
String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
|
String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
|
||||||
|
|||||||
@ -7,8 +7,8 @@ spring:
|
|||||||
import: optional:classpath:application-local.yml
|
import: optional:classpath:application-local.yml
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: ${UPLOAD_MAX_FILE_SIZE:200MB} # 업로드 영상(Whisper 전사용)
|
max-file-size: ${UPLOAD_MAX_FILE_SIZE:2GB} # YouTube 업로드 및 Whisper 전사용
|
||||||
max-request-size: ${UPLOAD_MAX_REQUEST_SIZE:200MB}
|
max-request-size: ${UPLOAD_MAX_REQUEST_SIZE:2GB}
|
||||||
datasource:
|
datasource:
|
||||||
url: ${DB_URL:}
|
url: ${DB_URL:}
|
||||||
driverClassName: org.postgresql.Driver
|
driverClassName: org.postgresql.Driver
|
||||||
|
|||||||
@ -20,14 +20,18 @@
|
|||||||
.us-channel-name { font-weight:750; font-size:1.05rem; }
|
.us-channel-name { font-weight:750; font-size:1.05rem; }
|
||||||
.us-muted { color:var(--text-2); font-size:.82rem; }
|
.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-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-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:7px; background:var(--studio-soft); border-top:2px solid var(--studio); position:relative; }
|
.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 span { position:absolute; top:calc(100% + 6px); left:50%; transform:translateX(-50%); font:600 .58rem/1 var(--font-mono); color:var(--text-3); }
|
.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 { 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-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-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 { 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 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-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 { padding:.85rem 1rem; border-right:1px solid var(--border); }
|
||||||
.us-stat:last-child { border:0; }
|
.us-stat:last-child { border:0; }
|
||||||
@ -58,6 +62,9 @@
|
|||||||
.us-badge.wait { color:#6655e8; background:var(--studio-soft); }
|
.us-badge.wait { color:#6655e8; background:var(--studio-soft); }
|
||||||
.us-badge.done { color:var(--success); background:rgba(var(--success-rgb),.1); }
|
.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.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-time { color:var(--text-2); font-size:.75rem; }
|
||||||
.us-panel { position:sticky; top:1rem; border:1px solid var(--border); background:var(--surface); }
|
.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); }
|
.us-panel-head { padding:1rem; border-bottom:1px solid var(--border); }
|
||||||
@ -90,7 +97,7 @@
|
|||||||
<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>
|
||||||
<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>
|
</header>
|
||||||
|
|
||||||
<section class="us-top" aria-label="채널과 폴더 현황">
|
<section class="us-top" aria-label="채널과 폴더 현황">
|
||||||
@ -102,22 +109,22 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="us-summary" aria-label="업로드 요약">
|
<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>
|
</section>
|
||||||
|
|
||||||
<div class="us-work">
|
<div class="us-work">
|
||||||
<section class="us-library">
|
<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-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>
|
<div class="us-list" id="videoList"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="us-panel" aria-label="업로드 설정">
|
<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>
|
<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)">
|
<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="uploadTitle">제목</label><input id="uploadTitle" 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="uploadDescription">설명</label><textarea id="uploadDescription"></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="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"><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>
|
<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>
|
<button class="us-submit us-primary" id="submitButton" type="submit">YouTube에 업로드</button>
|
||||||
@ -143,30 +150,36 @@
|
|||||||
<th:block layout:fragment="script">
|
<th:block layout:fragment="script">
|
||||||
<script>
|
<script>
|
||||||
let filter='all', uploadMode='now', descending=true;
|
let filter='all', uploadMode='now', descending=true;
|
||||||
let videos=[
|
let localVideos=[], uploadJobs=[], recentUploads=[], videos=[];
|
||||||
{id:1,name:'오늘 꼭 알아야 할 이슈 3가지.mp4',meta:'1080×1920 · 42초 · 86.4 MB',status:'wait',label:'업로드 대기'},
|
let selectedId=null;
|
||||||
{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;
|
|
||||||
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||||
function renderVideos(){
|
function renderVideos(){
|
||||||
const q=document.getElementById('videoSearch').value.toLowerCase();
|
const q=document.getElementById('videoSearch').value.toLowerCase();
|
||||||
const list=videos.filter(v=>(filter==='all'||v.status===filter)&&v.name.toLowerCase().includes(q));
|
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 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 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 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에 업로드';}
|
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(){
|
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').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 connections=[];
|
||||||
let oauthConfigured=false;
|
let oauthConfigured=false;
|
||||||
async function loadOAuthStatus(){
|
async function loadOAuthStatus(){
|
||||||
@ -200,6 +213,34 @@
|
|||||||
document.getElementById('channelName').textContent=c?c.title:'YouTube 채널을 연결하세요';
|
document.getElementById('channelName').textContent=c?c.title:'YouTube 채널을 연결하세요';
|
||||||
document.getElementById('channelMeta').textContent=c?(c.handle||c.channelId)+' · 토큰 '+(c.tokenStatus==='ACTIVE'?'정상':'확인 필요'):'Google OAuth 연결이 필요합니다.';
|
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('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){
|
async function syncStudio(silent=false){
|
||||||
if(!connections.length){if(!silent)connectYoutube();return;}
|
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();
|
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||'동기화 실패');
|
if(!response.ok||!body.success)throw new Error(body.message||'동기화 실패');
|
||||||
document.getElementById('recentCount').textContent=body.data.recentSevenDayCount;
|
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 동기화에 실패했습니다.');}
|
}catch(e){showToast(e.message||'YouTube 동기화에 실패했습니다.');}
|
||||||
}
|
}
|
||||||
function renderRhythm(days){
|
function renderRhythm(days){
|
||||||
const max=Math.max(1,...days.map(d=>d.count));const labels=['일','월','화','수','목','금','토'];
|
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(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('');
|
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);}
|
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');
|
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);}
|
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();
|
loadOAuthStatus();
|
||||||
loadConnections().then(()=>{if(connections.length)syncStudio(true);});
|
loadConnections().then(()=>{if(connections.length)syncStudio(true);});
|
||||||
</script>
|
</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