Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions src/main/java/com/slatto/domain/video/client/YoutubeApiClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.slatto.domain.video.client;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.slatto.global.exception.BaseException;
import com.slatto.global.response.code.CommonErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Component
public class YoutubeApiClient {

private static final String YOUTUBE_API_BASE_URL = "https://www.googleapis.com";
private static final List<String> THUMBNAIL_PRIORITIES =
List.of("maxres", "standard", "high", "medium", "default");

private final RestClient restClient;
private final String apiKey;

@Autowired
public YoutubeApiClient(
RestClient.Builder restClientBuilder,
@Value("${youtube.api.key}") String apiKey,
@Value("${youtube.api.connect-timeout}") Duration connectTimeout,
@Value("${youtube.api.read-timeout}") Duration readTimeout
) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(connectTimeout);
requestFactory.setReadTimeout(readTimeout);

this.restClient = restClientBuilder
.baseUrl(YOUTUBE_API_BASE_URL)
.requestFactory(requestFactory)
.build();
this.apiKey = apiKey;
}

YoutubeApiClient(RestClient restClient, String apiKey) {
this.restClient = restClient;
this.apiKey = apiKey;
}

public Optional<YoutubeVideoInfo> getVideo(String youtubeVideoId) {
YoutubeVideosResponse response;
try {
response = restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/youtube/v3/videos")
.queryParam("part", "snippet,contentDetails,status")
.queryParam("id", youtubeVideoId)
.queryParam("key", apiKey)
.build())
.retrieve()
.body(YoutubeVideosResponse.class);
} catch (RestClientException exception) {
throw new BaseException(CommonErrorCode.INTERNAL_SERVER_ERROR);
}

if (response == null || response.items() == null || response.items().isEmpty()) {
return Optional.empty();
}

YoutubeVideoItem item = response.items().getFirst();
String thumbnailUrl = selectThumbnailUrl(item.snippet().thumbnails());
int durationSeconds = Math.toIntExact(Duration.parse(item.contentDetails().duration()).getSeconds());

return Optional.of(new YoutubeVideoInfo(
item.snippet().title(),
thumbnailUrl,
durationSeconds,
item.status().embeddable(),
item.status().privacyStatus()
));
}

private String selectThumbnailUrl(Map<String, YoutubeThumbnail> thumbnails) {
if (thumbnails == null) {
return null;
}

return THUMBNAIL_PRIORITIES.stream()
.map(thumbnails::get)
.filter(thumbnail -> thumbnail != null && thumbnail.url() != null)
.map(YoutubeThumbnail::url)
.findFirst()
.orElse(null);
}

public record YoutubeVideoInfo(
String title,
String thumbnailUrl,
int durationSeconds,
boolean embeddable,
String privacyStatus
) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record YoutubeVideosResponse(List<YoutubeVideoItem> items) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record YoutubeVideoItem(
YoutubeSnippet snippet,
YoutubeContentDetails contentDetails,
YoutubeStatus status
) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record YoutubeSnippet(
String title,
Map<String, YoutubeThumbnail> thumbnails
) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record YoutubeThumbnail(String url) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record YoutubeContentDetails(String duration) {
}

@JsonIgnoreProperties(ignoreUnknown = true)
record YoutubeStatus(
boolean embeddable,
String privacyStatus
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.slatto.domain.video.controller;

import com.slatto.domain.video.dto.request.VideoRequest.VideoCreateReqDTO;
import com.slatto.domain.video.dto.request.VideoRequest.VideoUpdateReqDTO;
import com.slatto.domain.video.dto.response.VideoResponse.VideoCreateResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.VideoDeleteResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.VideoListResDTO;
import com.slatto.domain.video.dto.response.VideoResponse.VideoUpdateResDTO;
import com.slatto.domain.video.service.VideoService;
import com.slatto.global.response.ApiResponse;
import com.slatto.global.response.code.CommonSuccessCode;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@Validated
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/projects/{projectId}/videos")
public class VideoController {

private final VideoService videoService;

@PatchMapping("/{videoId}")
@Operation(summary = "μ˜μƒ μˆ˜μ •",
description = "ν”„λ‘œμ νŠΈμ— λ“±λ‘λœ μ˜μƒμ˜ 제λͺ©κ³Ό λ©”λͺ¨λ₯Ό μˆ˜μ •ν•©λ‹ˆλ‹€.<br>" +
"μˆ˜μ •ν•˜μ§€ μ•Šμ„ 뢀뢄은 null둜 λ„£μœΌλ©΄ λ©λ‹ˆλ‹€.")
public ApiResponse<VideoUpdateResDTO> updateVideo(
@PathVariable @Positive Long projectId,
@PathVariable @Positive Long videoId,
@Valid @RequestBody VideoUpdateReqDTO request
) {
// TODO: 인증/인가 κ΅¬ν˜„ ν›„ JWTμ—μ„œ memberId μΆ”μΆœν•˜λ„λ‘ λ³€κ²½
Long memberId = 1L;
return ApiResponse.success(
CommonSuccessCode.OK,
videoService.updateVideo(memberId, projectId, videoId, request)
Comment thread
young0206 marked this conversation as resolved.
);
}

@DeleteMapping("/{videoId}")
@Operation(summary = "μ˜μƒ μ‚­μ œ", description = "ν”„λ‘œμ νŠΈμ— λ“±λ‘λœ μ˜μƒμ„ μ‚­μ œν•©λ‹ˆλ‹€.")
public ApiResponse<VideoDeleteResDTO> deleteVideo(
@PathVariable @Positive Long projectId,
@PathVariable @Positive Long videoId
) {
// TODO: 인증/인가 κ΅¬ν˜„ ν›„ JWTμ—μ„œ memberId μΆ”μΆœν•˜λ„λ‘ λ³€κ²½
Long memberId = 1L;
return ApiResponse.success(
CommonSuccessCode.OK,
videoService.deleteVideo(memberId, projectId, videoId)
);
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "μ˜μƒ 등둝", description = "ν”„λ‘œμ νŠΈμ— μƒˆλ‘œμš΄ YouTube μ˜μƒμ„ λ“±λ‘ν•©λ‹ˆλ‹€.")
public ApiResponse<VideoCreateResDTO> createVideo(
@PathVariable @Positive Long projectId,
@Valid @RequestBody VideoCreateReqDTO request
) {
// TODO: 인증/인가 κ΅¬ν˜„ ν›„ JWTμ—μ„œ memberId μΆ”μΆœν•˜λ„λ‘ λ³€κ²½
Long memberId = 1L;
return ApiResponse.success(
CommonSuccessCode.CREATED,
videoService.createVideo(memberId, projectId, request)
);
}

@GetMapping
@Operation(summary = "μ˜μƒ λͺ©λ‘ 쑰회", description = "ν”„λ‘œμ νŠΈ 멀버가 ν”„λ‘œμ νŠΈμ— λ“±λ‘λœ μ˜μƒμ„ μ»€μ„œ λ°©μ‹μœΌλ‘œ μ‘°νšŒν•©λ‹ˆλ‹€.")
public ApiResponse<VideoListResDTO> getVideos(
@PathVariable @Positive Long projectId,
@RequestParam(required = false) @Positive Long cursor,
@RequestParam(required = false) @Min(1) @Max(100) Integer size
) {
// TODO: 인증/인가 κ΅¬ν˜„ ν›„ JWTμ—μ„œ memberId μΆ”μΆœν•˜λ„λ‘ λ³€κ²½
Long memberId = 1L;
return ApiResponse.success(
CommonSuccessCode.OK,
videoService.getVideos(memberId, projectId, cursor, size)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.slatto.domain.video.controller;

import com.slatto.domain.video.dto.request.VideoRequest.YoutubeValidateReqDTO;
import com.slatto.domain.video.dto.response.VideoResponse.YoutubeValidateResDTO;
import com.slatto.domain.video.service.VideoService;
import com.slatto.global.response.ApiResponse;
import com.slatto.global.response.code.CommonSuccessCode;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/videos/youtube")
public class YoutubeController {

private final VideoService videoService;

@PostMapping("/validate")
@Operation(summary = "YouTube URL 검증", description = "YouTube μ˜μƒ 정보와 ν”„λ‘œμ νŠΈ λ‚΄ 쀑볡 등둝 μ—¬λΆ€λ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€.")
public ApiResponse<YoutubeValidateResDTO> validateYoutubeUrl(
@Valid @RequestBody YoutubeValidateReqDTO request
) {
// TODO: 인증/인가 κ΅¬ν˜„ ν›„ JWTμ—μ„œ memberId μΆ”μΆœν•˜λ„λ‘ λ³€κ²½
Long memberId = 1L;
return ApiResponse.success(
CommonSuccessCode.OK,
videoService.validateYoutubeUrl(memberId, request)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.slatto.domain.video.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public class VideoRequest {

@Schema(description = "μ˜μƒ μˆ˜μ • μš”μ²­")
public record VideoUpdateReqDTO(
@Pattern(regexp = "(?s).*\\S.*", message = "μ˜μƒ 제λͺ©μ€ 곡백일 수 μ—†μŠ΅λ‹ˆλ‹€.")
@Size(max = 255, message = "μ˜μƒ 제λͺ©μ€ μ΅œλŒ€ 255μžκΉŒμ§€ μž…λ ₯ν•  수 μžˆμŠ΅λ‹ˆλ‹€.")
@Schema(example = "μˆ˜μ •λœ μ˜μƒ 제λͺ©", nullable = true)
String title,
Comment thread
young0206 marked this conversation as resolved.

@Schema(example = "μˆ˜μ •λœ μ˜μƒ λ©”λͺ¨", nullable = true)
String memo
) {
}

@Schema(description = "YouTube URL 검증 μš”μ²­")
public record YoutubeValidateReqDTO(
@NotBlank(message = "YouTube URL은 ν•„μˆ˜μž…λ‹ˆλ‹€.")
@Size(max = 500, message = "YouTube URL은 μ΅œλŒ€ 500μžκΉŒμ§€ μž…λ ₯ν•  수 μžˆμŠ΅λ‹ˆλ‹€.")
@Schema(example = "https://www.youtube.com/watch?v=abc123")
String youtubeUrl,

@NotNull(message = "ν”„λ‘œμ νŠΈ IDλŠ” ν•„μˆ˜μž…λ‹ˆλ‹€.")
@Positive(message = "ν”„λ‘œμ νŠΈ IDλŠ” μ–‘μˆ˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.")
@Schema(example = "10")
Long projectId
) {
}

@Schema(description = "μ˜μƒ 등둝 μš”μ²­")
public record VideoCreateReqDTO(
@NotBlank(message = "YouTube URL은 ν•„μˆ˜μž…λ‹ˆλ‹€.")
@Size(max = 500, message = "YouTube URL은 μ΅œλŒ€ 500μžκΉŒμ§€ μž…λ ₯ν•  수 μžˆμŠ΅λ‹ˆλ‹€.")
@Schema(example = "https://www.youtube.com/watch?v=abc123")
String youtubeUrl,

@NotBlank(message = "μ˜μƒ 제λͺ©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€.")
@Size(max = 255, message = "μ˜μƒ 제λͺ©μ€ μ΅œλŒ€ 255μžκΉŒμ§€ μž…λ ₯ν•  수 μžˆμŠ΅λ‹ˆλ‹€.")
@Schema(example = "ν”„λ‘œμ νŠΈ λͺ…")
String title,

@Schema(example = "μ˜μƒμ— κ΄€λ ¨λœ λ©”λͺ¨", nullable = true)
String memo
) {
}
}
Loading
Loading