From 2a33dee376a2ca25e2e47685a5cb311216bd8ebc Mon Sep 17 00:00:00 2001 From: Roman Maher Date: Sun, 3 Nov 2024 12:10:42 +0200 Subject: [PATCH] Implement video data retrieval & saving (#35) * Add Liquibase dependency * Add Liquibase migration to init schema & master changelog * Configure Liquibase to work with Spring * Add entities representing DB tables * Remove VideoImport entity * Add changeset into the master changelog * Add repositories for Playlists and Videos * Implement service allowing saving videos into the DB from a CVS file * Utilize ImportService in LibraryImportController * Add file to init DB schema & update docker-compose to use it * Add a changelog to populate the Playlists table with "Watch later" * Implement the ability to import also by providing a list of videos' IDs * Add more alignment rules into .editorconfig * Update gradle wrapper * Add a couple of ruled for Java in .editorconfig * Update Spring version to 3.3.4 * Move SecurityConfiguration to a different package * Add more rules to .editorconfig * Update exception handling * Finish import controller & corresponding services * Add indentation rule to .editorconfig * Extract interface & handle cases when video_id is already present in DB * Update event for saving videos VideoData is going to be saved only if it has changed or is not present for the given video * Fixed getting NPE when parsing tags * Disable liquibase for unit tests * Update dependencies * Remove H2 database dependency --- .editorconfig | 15 ++ .github/workflows/gradle.yml | 2 + build.gradle.kts | 11 +- compose.yaml | 2 + gradle/wrapper/gradle-wrapper.jar | Bin 43504 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- init.sql | 1 + .../SecurityConfiguration.java | 2 +- .../com/ypm/constant/ProcessingStatus.java | 8 + .../controller/LibraryImportController.java | 56 +++++-- .../com/ypm/dto/BatchProcessingStatus.java | 37 +++++ src/main/java/com/ypm/dto/VideoImportDto.java | 6 + .../com/ypm/error/GlobalExceptionHandler.java | 40 ++--- .../exception/PlayListNotFoundException.java | 4 + .../com/ypm/persistence/entity/Playlist.java | 32 ++++ .../com/ypm/persistence/entity/Video.java | 45 ++++++ .../com/ypm/persistence/entity/VideoData.java | 42 ++++++ .../ypm/persistence/entity/VideoImport.java | 26 ---- .../persistence/event/VideosSavedEvent.java | 12 ++ .../repository/PlaylistRepository.java | 13 ++ .../repository/VideoRepository.java | 8 +- .../com/ypm/service/BatchStatusManager.java | 45 ++++++ .../ypm/service/youtube/ImportService.java | 40 +---- .../service/youtube/ImportServiceImpl.java | 140 ++++++++++++++++++ .../youtube/event/VideoEventListener.java | 84 +++++++++++ src/main/resources/application-test.yml | 3 + src/main/resources/application.yml | 5 + .../db/changelog/2024/09/19-01-changelog.sql | 25 ++++ .../db/changelog/2024/09/26-01-changelog.sql | 5 + .../db/changelog/db.changelog-master.yaml | 5 + .../db/changelog/db.changeset-init-schema.sql | 35 +++++ 31 files changed, 652 insertions(+), 99 deletions(-) create mode 100644 init.sql rename src/main/java/com/ypm/config/{security => spring}/SecurityConfiguration.java (97%) create mode 100644 src/main/java/com/ypm/constant/ProcessingStatus.java create mode 100644 src/main/java/com/ypm/dto/BatchProcessingStatus.java create mode 100644 src/main/java/com/ypm/dto/VideoImportDto.java create mode 100644 src/main/java/com/ypm/persistence/entity/Playlist.java create mode 100644 src/main/java/com/ypm/persistence/entity/Video.java create mode 100644 src/main/java/com/ypm/persistence/entity/VideoData.java delete mode 100644 src/main/java/com/ypm/persistence/entity/VideoImport.java create mode 100644 src/main/java/com/ypm/persistence/event/VideosSavedEvent.java create mode 100644 src/main/java/com/ypm/persistence/repository/PlaylistRepository.java create mode 100644 src/main/java/com/ypm/service/BatchStatusManager.java create mode 100644 src/main/java/com/ypm/service/youtube/ImportServiceImpl.java create mode 100644 src/main/java/com/ypm/service/youtube/event/VideoEventListener.java create mode 100644 src/main/resources/application-test.yml create mode 100644 src/main/resources/db/changelog/2024/09/19-01-changelog.sql create mode 100644 src/main/resources/db/changelog/2024/09/26-01-changelog.sql create mode 100644 src/main/resources/db/changelog/db.changelog-master.yaml create mode 100644 src/main/resources/db/changelog/db.changeset-init-schema.sql 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 2c3521197d7c4586c843d1d3e9090525f1898cde..a4b76b9530d66f5e68d973ea569d8e19de379189 100644 GIT binary patch delta 3990 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 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