Skip to content

Commit

Permalink
Implement video data retrieval & saving (#35)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
leingenm authored Nov 3, 2024
1 parent 8cfca26 commit 2a33dee
Show file tree
Hide file tree
Showing 31 changed files with 652 additions and 99 deletions.
15 changes: 15 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,6 @@ jobs:
createPR: false

- name: Build with Gradle Wrapper
env:
SPRING_PROFILES_ACTIVE: test
run: ./gradlew build
11 changes: 6 additions & 5 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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"
}

Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Binary file modified gradle/wrapper/gradle-wrapper.jar
Binary file not shown.
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE SCHEMA IF NOT EXISTS ypm;
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/com/ypm/constant/ProcessingStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ypm.constant;

public enum ProcessingStatus {
PROCESSING,
COMPLETED,
FAILED,
NOT_FOUND
}
56 changes: 46 additions & 10 deletions src/main/java/com/ypm/controller/LibraryImportController.java
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,16 +17,52 @@ public class LibraryImportController {

private final ImportService importService;

@PostMapping("/watch-later")
public ResponseEntity<String> 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<String> 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<BatchProcessingStatus> 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<String> validateRequest(String playlistName, boolean isNullOrEmpty, String errorMessage) {
if (playlistName == null || playlistName.isEmpty()) {
return ResponseEntity.badRequest().body("Playlist name was not provided");
}

List<VideoImport> 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;
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/ypm/dto/BatchProcessingStatus.java
Original file line number Diff line number Diff line change
@@ -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<String> failedVideoIds;

public BatchProcessingStatus(ProcessingStatus status) {
this.status = status;
this.failedVideoIds = new ArrayList<>();
}

public void addFailedVideoId(String videoId) {
failedVideoIds.add(videoId);
}

public void addFailedVideoIds(List<String> videoIds) {
failedVideoIds.addAll(videoIds);
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/ypm/dto/VideoImportDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.ypm.dto;

import java.time.OffsetDateTime;

public record VideoImportDto(String id, OffsetDateTime importDate) {
}
40 changes: 20 additions & 20 deletions src/main/java/com/ypm/error/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,6 +13,8 @@
import java.io.IOException;
import java.time.Instant;

import static org.springframework.http.HttpStatus.*;

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
32 changes: 32 additions & 0 deletions src/main/java/com/ypm/persistence/entity/Playlist.java
Original file line number Diff line number Diff line change
@@ -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<Video> videos = new LinkedHashSet<>();
}
45 changes: 45 additions & 0 deletions src/main/java/com/ypm/persistence/entity/Video.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.ypm.persistence.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDate;

@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "videos", schema = "ypm")
public class Video {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Long id;

@Column(name = "youtube_id", nullable = false, length = Integer.MAX_VALUE, unique = true)
private String youtubeId;

@Column(name = "import_date")
private LocalDate importDate;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "playlist_id", nullable = false)
private Playlist playlist;

@OneToOne(mappedBy = "video", cascade = CascadeType.ALL)
private VideoData videoData;

public Video(String youtubeId, LocalDate importDate) {
this.youtubeId = youtubeId;
this.importDate = importDate;
}

public Video(String youtubeId, LocalDate importDate, Playlist playlist) {
this.youtubeId = youtubeId;
this.importDate = importDate;
this.playlist = playlist;
}
}
Loading

0 comments on commit 2a33dee

Please sign in to comment.