diff --git a/src/main/java/com/slatto/domain/video/client/YoutubeApiClient.java b/src/main/java/com/slatto/domain/video/client/YoutubeApiClient.java new file mode 100644 index 00000000..c2b60893 --- /dev/null +++ b/src/main/java/com/slatto/domain/video/client/YoutubeApiClient.java @@ -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 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 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 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 items) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record YoutubeVideoItem( + YoutubeSnippet snippet, + YoutubeContentDetails contentDetails, + YoutubeStatus status + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record YoutubeSnippet( + String title, + Map thumbnails + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record YoutubeThumbnail(String url) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record YoutubeContentDetails(String duration) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + record YoutubeStatus( + boolean embeddable, + String privacyStatus + ) { + } +} diff --git a/src/main/java/com/slatto/domain/video/controller/VideoController.java b/src/main/java/com/slatto/domain/video/controller/VideoController.java new file mode 100644 index 00000000..0afd8e1a --- /dev/null +++ b/src/main/java/com/slatto/domain/video/controller/VideoController.java @@ -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 = "프로젝트에 등록된 영상의 제목과 메모를 수정합니다.
" + + "수정하지 않을 부분은 null로 넣으면 됩니다.") + public ApiResponse 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) + ); + } + + @DeleteMapping("/{videoId}") + @Operation(summary = "영상 삭제", description = "프로젝트에 등록된 영상을 삭제합니다.") + public ApiResponse 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 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 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) + ); + } +} diff --git a/src/main/java/com/slatto/domain/video/controller/YoutubeController.java b/src/main/java/com/slatto/domain/video/controller/YoutubeController.java new file mode 100644 index 00000000..9bee96be --- /dev/null +++ b/src/main/java/com/slatto/domain/video/controller/YoutubeController.java @@ -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 validateYoutubeUrl( + @Valid @RequestBody YoutubeValidateReqDTO request + ) { + // TODO: 인증/인가 구현 후 JWT에서 memberId 추출하도록 변경 + Long memberId = 1L; + return ApiResponse.success( + CommonSuccessCode.OK, + videoService.validateYoutubeUrl(memberId, request) + ); + } +} diff --git a/src/main/java/com/slatto/domain/video/dto/request/VideoRequest.java b/src/main/java/com/slatto/domain/video/dto/request/VideoRequest.java new file mode 100644 index 00000000..7b3a3d95 --- /dev/null +++ b/src/main/java/com/slatto/domain/video/dto/request/VideoRequest.java @@ -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, + + @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 + ) { + } +} diff --git a/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java b/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java new file mode 100644 index 00000000..1f61baab --- /dev/null +++ b/src/main/java/com/slatto/domain/video/dto/response/VideoResponse.java @@ -0,0 +1,89 @@ +package com.slatto.domain.video.dto.response; + +import com.slatto.domain.video.entity.Video; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; + +public class VideoResponse { + + @Schema(description = "영상 수정 응답") + public record VideoUpdateResDTO( + @Schema(example = "1") Long videoId, + @Schema(example = "수정된 영상 제목") String title, + @Schema(example = "수정된 영상 메모", nullable = true) String memo, + LocalDateTime updatedAt + ) { + public static VideoUpdateResDTO from(Video video) { + return new VideoUpdateResDTO( + video.getId(), video.getTitle(), video.getMemo(), video.getUpdatedAt() + ); + } + } + + @Schema(description = "영상 삭제 응답") + public record VideoDeleteResDTO( + @Schema(example = "1") Long videoId, + @Schema(example = "영상이 삭제되었습니다.") String message + ) { + } + + @Schema(description = "YouTube URL 검증 응답") + public record YoutubeValidateResDTO( + @Schema(example = "true") boolean valid, + @Schema(example = "abc123") String youtubeVideoId, + @Schema(example = "영상 제목") String title, + @Schema(example = "https://img.youtube.com/vi/abc123/maxresdefault.jpg") String thumbnailUrl, + @Schema(example = "1018") int durationSeconds, + @Schema(example = "true") boolean playable, + @Schema(example = "등록 가능한 영상입니다.") String message + ) { + } + + @Schema(description = "영상 등록 응답") + public record VideoCreateResDTO( + @Schema(example = "1") Long videoId, + @Schema(example = "프로젝트 명") String title, + @Schema(example = "https://img.youtube.com/vi/abc123/maxresdefault.jpg") String thumbnailUrl, + @Schema(example = "1018") Integer durationSeconds, + @Schema(example = "false") boolean bookmarked, + @Schema(example = "IN_PROGRESS") String progressStatus, + LocalDateTime createdAt + ) { + public static VideoCreateResDTO from(Video video) { + return new VideoCreateResDTO( + video.getId(), video.getTitle(), video.getThumbnailUrl(), video.getDurationSeconds(), false, + video.getProgressStatus().name(), video.getCreatedAt() + ); + } + } + + @Schema(description = "영상 목록 조회 응답") + public record VideoListResDTO( + List items, + @Schema(description = "다음 페이지 조회 커서", example = "9", nullable = true) + Long nextCursor, + @Schema(description = "다음 목록 존재 여부", example = "true") + boolean hasNext + ) { + } + + public record VideoItemResDTO( + @Schema(example = "10") Long videoId, + @Schema(example = "프로젝트 명") String title, + @Schema(example = "https://img.youtube.com/vi/abc123/maxresdefault.jpg") String thumbnailUrl, + @Schema(example = "true") boolean bookmarked, + @Schema(example = "IN_PROGRESS") String progressStatus, + @Schema(example = "3") int unreadCommentCount, + LocalDateTime createdAt, + LocalDateTime updatedAt + ) { + public static VideoItemResDTO from(Video video, boolean bookmarked) { + return new VideoItemResDTO( + video.getId(), video.getTitle(), video.getThumbnailUrl(), bookmarked, + video.getProgressStatus().name(), 0, video.getCreatedAt(), video.getUpdatedAt() + ); + } + } +} diff --git a/src/main/java/com/slatto/domain/video/entity/Video.java b/src/main/java/com/slatto/domain/video/entity/Video.java index 8b71bb73..270f0f4a 100644 --- a/src/main/java/com/slatto/domain/video/entity/Video.java +++ b/src/main/java/com/slatto/domain/video/entity/Video.java @@ -2,17 +2,26 @@ import com.slatto.domain.common.entity.BaseEntity; import com.slatto.domain.project.entity.Project; +import com.slatto.domain.video.enums.VideoProgressStatus; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @Entity -@Table(name = "video") +@Table( + name = "video", + uniqueConstraints = @UniqueConstraint( + name = "uq_video_project_youtube_video_id", + columnNames = {"project_id", "youtube_video_id"} + ) +) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Video extends BaseEntity { + private static final VideoProgressStatus DEFAULT_PROGRESS_STATUS = VideoProgressStatus.IN_PROGRESS; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) @@ -34,6 +43,53 @@ public class Video extends BaseEntity { @Column(name = "thumbnail_url", nullable = true, length = 500) private String thumbnailUrl; + @Column(name = "duration_seconds", nullable = true) + private Integer durationSeconds; + + @Enumerated(EnumType.STRING) @Column(name = "progress_status", nullable = false, length = 50) - private String progressStatus; -} \ No newline at end of file + private VideoProgressStatus progressStatus; + + @Column(name = "memo", columnDefinition = "TEXT") + private String memo; + + private Video( + Project project, + String youtubeUrl, + String youtubeVideoId, + String title, + String thumbnailUrl, + Integer durationSeconds, + String memo + ) { + this.project = project; + this.youtubeUrl = youtubeUrl; + this.youtubeVideoId = youtubeVideoId; + this.title = title; + this.thumbnailUrl = thumbnailUrl; + this.durationSeconds = durationSeconds; + this.progressStatus = DEFAULT_PROGRESS_STATUS; + this.memo = memo; + } + + public static Video create( + Project project, + String youtubeUrl, + String youtubeVideoId, + String title, + String thumbnailUrl, + Integer durationSeconds, + String memo + ) { + return new Video(project, youtubeUrl, youtubeVideoId, title, thumbnailUrl, durationSeconds, memo); + } + + public void updateInfo(String title, String memo) { + if (title != null) { + this.title = title; + } + if (memo != null) { + this.memo = memo; + } + } +} diff --git a/src/main/java/com/slatto/domain/video/enums/VideoProgressStatus.java b/src/main/java/com/slatto/domain/video/enums/VideoProgressStatus.java new file mode 100644 index 00000000..df6904ce --- /dev/null +++ b/src/main/java/com/slatto/domain/video/enums/VideoProgressStatus.java @@ -0,0 +1,6 @@ +package com.slatto.domain.video.enums; + +public enum VideoProgressStatus { + IN_PROGRESS, + DONE +} diff --git a/src/main/java/com/slatto/domain/video/repository/VideoBookmarkRepository.java b/src/main/java/com/slatto/domain/video/repository/VideoBookmarkRepository.java new file mode 100644 index 00000000..9a3053e7 --- /dev/null +++ b/src/main/java/com/slatto/domain/video/repository/VideoBookmarkRepository.java @@ -0,0 +1,25 @@ +package com.slatto.domain.video.repository; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class VideoBookmarkRepository { + + private final ObjectProvider entityManagerProvider; + + public List findBookmarkedVideoIdsByUserIdAndVideoIds(Long userId, List videoIds) { + return entityManagerProvider.getObject().createQuery(""" + select bookmark.video.id from VideoBookmark bookmark + where bookmark.user.id = :userId and bookmark.video.id in :videoIds + """, Long.class) + .setParameter("userId", userId) + .setParameter("videoIds", videoIds) + .getResultList(); + } +} diff --git a/src/main/java/com/slatto/domain/video/repository/VideoProjectAccessRepository.java b/src/main/java/com/slatto/domain/video/repository/VideoProjectAccessRepository.java new file mode 100644 index 00000000..05351404 --- /dev/null +++ b/src/main/java/com/slatto/domain/video/repository/VideoProjectAccessRepository.java @@ -0,0 +1,48 @@ +package com.slatto.domain.video.repository; + +import com.slatto.domain.project.entity.Project; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class VideoProjectAccessRepository { + + private final ObjectProvider entityManagerProvider; + + public Optional findProjectById(Long projectId) { + return entityManagerProvider.getObject().createQuery(""" + select project from Project project + where project.id = :projectId and project.deletedAt is null + """, Project.class) + .setParameter("projectId", projectId) + .getResultStream() + .findFirst(); + } + + public boolean existsByMemberIdAndProjectId(Long memberId, Long projectId) { + return entityManagerProvider.getObject().createQuery(""" + select count(member) from ProjectMember member + where member.user.id = :memberId + and member.project.id = :projectId + and member.project.deletedAt is null + and member.leftAt is null + """, Long.class) + .setParameter("memberId", memberId) + .setParameter("projectId", projectId) + .getSingleResult() > 0; + } + + public boolean projectExistsById(Long projectId) { + return entityManagerProvider.getObject().createQuery(""" + select count(project) from Project project + where project.id = :projectId and project.deletedAt is null + """, Long.class) + .setParameter("projectId", projectId) + .getSingleResult() > 0; + } +} diff --git a/src/main/java/com/slatto/domain/video/repository/VideoRepository.java b/src/main/java/com/slatto/domain/video/repository/VideoRepository.java new file mode 100644 index 00000000..cb213321 --- /dev/null +++ b/src/main/java/com/slatto/domain/video/repository/VideoRepository.java @@ -0,0 +1,64 @@ +package com.slatto.domain.video.repository; + +import com.slatto.domain.video.entity.Video; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class VideoRepository { + + private final ObjectProvider entityManagerProvider; + + public Video save(Video video) { + entityManagerProvider.getObject().persist(video); + return video; + } + + public boolean existsByProjectIdAndYoutubeVideoId(Long projectId, String youtubeVideoId) { + return entityManagerProvider.getObject().createQuery(""" + select count(video) from Video video + where video.project.id = :projectId + and video.youtubeVideoId = :youtubeVideoId + """, Long.class) + .setParameter("projectId", projectId) + .setParameter("youtubeVideoId", youtubeVideoId) + .getSingleResult() > 0; + } + + public Optional