diff --git a/.editorconfig b/.editorconfig index ba29d48..ba9b290 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,8 +9,23 @@ insert_final_newline = true max_line_length = 100 [*.java] +max_line_length = 120 +ij_java_keep_simple_lambdas_in_one_line = true ij_java_align_multiline_chained_methods = true +ij_java_align_multiline_parameters = true +ij_java_align_multiline_parameters_in_calls = true +ij_java_align_multiline_throws_list = true ij_java_align_multiline_extends_list = true +ij_java_align_multiline_ternary_operation = true +ij_java_align_multiline_records = true +ij_java_record_components_wrap = on_every_item +ij_java_keep_builder_methods_indents = true +ij_java_align_subsequent_simple_methods = true +ij_java_keep_simple_methods_in_one_line = true +ij_java_method_call_chain_wrap = normal +ij_java_call_parameters_wrap = normal +ij_java_method_parameters_wrap = normal +ij_java_continuation_indent_size = 8 [*.yml] indent_size = 2 diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 33934ba..ac5938c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -52,4 +52,6 @@ jobs: createPR: false - name: Build with Gradle Wrapper + env: + SPRING_PROFILES_ACTIVE: test run: ./gradlew build diff --git a/build.gradle.kts b/build.gradle.kts index 09f1dc4..e595c4f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,6 @@ plugins { java - id("org.springframework.boot") version "3.3.3" + id("org.springframework.boot") version "3.3.5" id("io.spring.dependency-management") version "1.1.6" } @@ -33,6 +33,7 @@ dependencies { // Data Access implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.liquibase:liquibase-core") runtimeOnly("org.postgresql:postgresql") // Dev @@ -41,11 +42,11 @@ dependencies { annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") // YouTube Client - implementation("com.google.apis:google-api-services-youtube:v3-rev20240310-2.0.0") - implementation("com.google.api-client:google-api-client:2.6.0") - implementation("com.google.http-client:google-http-client:1.44.1") + implementation("com.google.apis:google-api-services-youtube:v3-rev20241022-2.0.0") + implementation("com.google.api-client:google-api-client:2.7.0") + implementation("com.google.http-client:google-http-client:1.45.0") implementation("com.google.oauth-client:google-oauth-client-jetty:1.36.0") - implementation("com.google.code.gson:gson:2.10") + implementation("com.google.code.gson:gson:2.11.0") // Lombok compileOnly("org.projectlombok:lombok") diff --git a/compose.yaml b/compose.yaml index 91d7f0e..8a4876f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,6 +8,8 @@ services: ports: - '5432:5432' volumes: + # DEV-NOTE: Used to initialize the DB schema, otherwise Liquibase migrations would fail + - ./init.sql:/docker-entrypoint-initdb.d/init.sql - ypm-db:/var/lib/postgresql/data volumes: diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 2c35211..a4b76b9 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..df97d72 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/init.sql b/init.sql new file mode 100644 index 0000000..1faadec --- /dev/null +++ b/init.sql @@ -0,0 +1 @@ +CREATE SCHEMA IF NOT EXISTS ypm; diff --git a/src/main/java/com/ypm/config/security/SecurityConfiguration.java b/src/main/java/com/ypm/config/spring/SecurityConfiguration.java similarity index 97% rename from src/main/java/com/ypm/config/security/SecurityConfiguration.java rename to src/main/java/com/ypm/config/spring/SecurityConfiguration.java index f79c2fa..205e490 100644 --- a/src/main/java/com/ypm/config/security/SecurityConfiguration.java +++ b/src/main/java/com/ypm/config/spring/SecurityConfiguration.java @@ -1,4 +1,4 @@ -package com.ypm.config.security; +package com.ypm.config.spring; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/ypm/constant/ProcessingStatus.java b/src/main/java/com/ypm/constant/ProcessingStatus.java new file mode 100644 index 0000000..6fc0e41 --- /dev/null +++ b/src/main/java/com/ypm/constant/ProcessingStatus.java @@ -0,0 +1,8 @@ +package com.ypm.constant; + +public enum ProcessingStatus { + PROCESSING, + COMPLETED, + FAILED, + NOT_FOUND +} diff --git a/src/main/java/com/ypm/controller/LibraryImportController.java b/src/main/java/com/ypm/controller/LibraryImportController.java index cbdd242..e18968a 100644 --- a/src/main/java/com/ypm/controller/LibraryImportController.java +++ b/src/main/java/com/ypm/controller/LibraryImportController.java @@ -1,13 +1,13 @@ package com.ypm.controller; -import com.ypm.persistence.entity.VideoImport; +import com.ypm.constant.ProcessingStatus; +import com.ypm.dto.BatchProcessingStatus; import com.ypm.service.youtube.ImportService; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; import java.util.List; @RestController @@ -17,16 +17,52 @@ public class LibraryImportController { private final ImportService importService; - @PostMapping("/watch-later") - public ResponseEntity importWatchLaterLibrary(@RequestParam("file") MultipartFile file) throws IOException { - if (file.isEmpty()) { - return ResponseEntity.badRequest().build(); + @PostMapping("/file") + public ResponseEntity importVideos(@RequestParam("playlist-name") String playlistName, + @RequestParam("file") MultipartFile file) { + var validationResponse = + validateRequest(playlistName, file == null || file.isEmpty(), "File has no data or was not provided."); + if (validationResponse != null) return validationResponse; + + var processingIds = importService.importVideos(playlistName, file); + + return ResponseEntity.accepted().body(processingIds); + } + + @PostMapping + public ResponseEntity importVideos(@RequestParam("playlist-name") String playlistName, + @RequestBody List videosIds) { + var validationResponse = + validateRequest(playlistName, videosIds.isEmpty(), "Videos IDs were not provided"); + if (validationResponse != null) return validationResponse; + + var processingId = importService.importVideos(playlistName, videosIds); + + return ResponseEntity.accepted().body(processingId); + } + + @GetMapping("/status/{processing-id}") + public ResponseEntity checkStatus(@PathVariable("processing-id") String processingId) { + var processingStatus = importService.checkProcessingStatus(processingId); + + if (processingStatus.getStatus() == ProcessingStatus.COMPLETED) { + return ResponseEntity.ok(processingStatus); + } else if (processingStatus.getStatus() == ProcessingStatus.FAILED) { + return ResponseEntity.internalServerError().body(processingStatus); + } else { + return ResponseEntity.accepted().body(processingStatus); + } + } + + private static ResponseEntity validateRequest(String playlistName, boolean isNullOrEmpty, String errorMessage) { + if (playlistName == null || playlistName.isEmpty()) { + return ResponseEntity.badRequest().body("Playlist name was not provided"); } - List savedVideos; - savedVideos = importService.importCsv(file); + if (isNullOrEmpty) { + return ResponseEntity.badRequest().body(errorMessage); + } - var responseBody = String.format("Saved %s videos", savedVideos.size()); - return ResponseEntity.ok().body(responseBody); + return null; } } diff --git a/src/main/java/com/ypm/dto/BatchProcessingStatus.java b/src/main/java/com/ypm/dto/BatchProcessingStatus.java new file mode 100644 index 0000000..db2458f --- /dev/null +++ b/src/main/java/com/ypm/dto/BatchProcessingStatus.java @@ -0,0 +1,37 @@ +package com.ypm.dto; + +import com.ypm.constant.ProcessingStatus; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@ToString +@RequiredArgsConstructor +public final class BatchProcessingStatus { + + @Setter + private ProcessingStatus status; + + @Setter + private String errorMessage; + + private List failedVideoIds; + + public BatchProcessingStatus(ProcessingStatus status) { + this.status = status; + this.failedVideoIds = new ArrayList<>(); + } + + public void addFailedVideoId(String videoId) { + failedVideoIds.add(videoId); + } + + public void addFailedVideoIds(List videoIds) { + failedVideoIds.addAll(videoIds); + } +} diff --git a/src/main/java/com/ypm/dto/VideoImportDto.java b/src/main/java/com/ypm/dto/VideoImportDto.java new file mode 100644 index 0000000..d6dda38 --- /dev/null +++ b/src/main/java/com/ypm/dto/VideoImportDto.java @@ -0,0 +1,6 @@ +package com.ypm.dto; + +import java.time.OffsetDateTime; + +public record VideoImportDto(String id, OffsetDateTime importDate) { +} diff --git a/src/main/java/com/ypm/error/GlobalExceptionHandler.java b/src/main/java/com/ypm/error/GlobalExceptionHandler.java index 6bed96b..5e3ea5e 100644 --- a/src/main/java/com/ypm/error/GlobalExceptionHandler.java +++ b/src/main/java/com/ypm/error/GlobalExceptionHandler.java @@ -4,7 +4,6 @@ import com.ypm.dto.response.ExceptionResponse; import com.ypm.exception.PlayListNotFoundException; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -14,6 +13,8 @@ import java.io.IOException; import java.time.Instant; +import static org.springframework.http.HttpStatus.*; + @RestControllerAdvice public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { @@ -22,35 +23,34 @@ public GlobalExceptionHandler() { } @ExceptionHandler({GoogleJsonResponseException.class}) - public ResponseEntity handleBadRequest(final GoogleJsonResponseException ex, - final WebRequest request) { - - ExceptionResponse exceptionResponse = new ExceptionResponse( - ex.getStatusCode(), ex.getDetails().getMessage(), Instant.now()); + public ResponseEntity handleBadRequest(final GoogleJsonResponseException ex, final WebRequest request) { + ExceptionResponse exceptionResponse = new ExceptionResponse(ex.getStatusCode(), ex.getDetails().getMessage(), + Instant.now()); - return handleExceptionInternal(ex, exceptionResponse, - new HttpHeaders(), HttpStatus.valueOf(ex.getStatusCode()), request); + return handleExceptionInternal(ex, exceptionResponse, new HttpHeaders(), valueOf(ex.getStatusCode()), request); } @ExceptionHandler({PlayListNotFoundException.class}) - public ResponseEntity handleBadRequest(final PlayListNotFoundException ex, - final WebRequest request) { + public ResponseEntity handleBadRequest(final PlayListNotFoundException ex, final WebRequest request) { - ExceptionResponse exceptionResponse = new ExceptionResponse( - HttpStatus.NOT_FOUND.value(), ex.getMessage(), Instant.now()); + ExceptionResponse exceptionResponse = new ExceptionResponse(NOT_FOUND.value(), ex.getMessage(), Instant.now()); - return handleExceptionInternal(ex, exceptionResponse, - new HttpHeaders(), HttpStatus.NOT_FOUND, request); + return handleExceptionInternal(ex, exceptionResponse, new HttpHeaders(), NOT_FOUND, request); } @ExceptionHandler({IOException.class}) - public ResponseEntity handleInternal(final IOException ex, - final WebRequest request) { + public ResponseEntity handleInternal(final IOException ex, final WebRequest request) { + ExceptionResponse exceptionResponse = new ExceptionResponse(INTERNAL_SERVER_ERROR.value(), ex.getMessage(), + Instant.now()); + + return handleExceptionInternal(ex, exceptionResponse, new HttpHeaders(), INTERNAL_SERVER_ERROR, request); + } - ExceptionResponse exceptionResponse = new ExceptionResponse( - HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getMessage(), Instant.now()); + @ExceptionHandler({RuntimeException.class}) + public ResponseEntity handleRuntime(final RuntimeException ex, final WebRequest request) { + ExceptionResponse exceptionResponse = new ExceptionResponse(INTERNAL_SERVER_ERROR.value(), ex.getMessage(), + Instant.now()); - return handleExceptionInternal(ex, exceptionResponse, - new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request); + return handleExceptionInternal(ex, exceptionResponse, new HttpHeaders(), INTERNAL_SERVER_ERROR, request); } } diff --git a/src/main/java/com/ypm/exception/PlayListNotFoundException.java b/src/main/java/com/ypm/exception/PlayListNotFoundException.java index 4d42e89..97f34cd 100644 --- a/src/main/java/com/ypm/exception/PlayListNotFoundException.java +++ b/src/main/java/com/ypm/exception/PlayListNotFoundException.java @@ -5,4 +5,8 @@ public class PlayListNotFoundException extends RuntimeException { public PlayListNotFoundException(String identifier, String message) { super(String.format("Playlist with the '%s' identifier was not found. %s", identifier, message)); } + + public PlayListNotFoundException(String message) { + super(message); + } } diff --git a/src/main/java/com/ypm/persistence/entity/Playlist.java b/src/main/java/com/ypm/persistence/entity/Playlist.java new file mode 100644 index 0000000..5071b1e --- /dev/null +++ b/src/main/java/com/ypm/persistence/entity/Playlist.java @@ -0,0 +1,32 @@ +package com.ypm.persistence.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.LinkedHashSet; +import java.util.Set; + +@Getter +@Setter +@Entity +@Table(name = "playlists", schema = "ypm") +public class Playlist { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description", length = Integer.MAX_VALUE) + private String description; + + @Column(name = "status", length = Integer.MAX_VALUE) + private String status; + + @OneToMany(mappedBy = "playlist") + private Set