Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement video data retrieval & saving #35

Merged
merged 27 commits into from
Nov 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d732cb2
Add Liquibase dependency
leingenm Sep 1, 2024
b7203fd
Add Liquibase migration to init schema & master changelog
leingenm Sep 7, 2024
526f049
Configure Liquibase to work with Spring
leingenm Sep 7, 2024
62ac53e
Add entities representing DB tables
leingenm Sep 7, 2024
cd9de0c
Remove VideoImport entity
leingenm Sep 20, 2024
30be7ff
Add changeset into the master changelog
leingenm Sep 20, 2024
89e6356
Add repositories for Playlists and Videos
leingenm Sep 20, 2024
db769be
Implement service allowing saving videos into the DB from a CVS file
leingenm Sep 20, 2024
b0c0601
Utilize ImportService in LibraryImportController
leingenm Sep 20, 2024
deff2cc
Add file to init DB schema & update docker-compose to use it
leingenm Sep 20, 2024
f228e17
Add a changelog to populate the Playlists table with "Watch later"
leingenm Sep 26, 2024
a0a53bb
Implement the ability to import also by providing a list of videos' IDs
leingenm Sep 26, 2024
af1b7b5
Add more alignment rules into .editorconfig
leingenm Sep 27, 2024
e5636de
Update gradle wrapper
leingenm Oct 8, 2024
76b8823
Add a couple of ruled for Java in .editorconfig
leingenm Oct 8, 2024
5366bf9
Update Spring version to 3.3.4
leingenm Oct 8, 2024
24ecc94
Move SecurityConfiguration to a different package
leingenm Oct 8, 2024
44479c5
Add more rules to .editorconfig
leingenm Oct 8, 2024
b5b343f
Update exception handling
leingenm Oct 8, 2024
41c6451
Finish import controller & corresponding services
leingenm Oct 8, 2024
d3155ea
Add indentation rule to .editorconfig
leingenm Oct 9, 2024
c6e145a
Extract interface & handle cases when video_id is already present in DB
leingenm Oct 9, 2024
4cfd021
Update event for saving videos
leingenm Oct 9, 2024
d25cf15
Fixed getting NPE when parsing tags
leingenm Oct 11, 2024
b5d6709
Disable liquibase for unit tests
leingenm Oct 26, 2024
2d24cbc
Update dependencies
leingenm Oct 26, 2024
63c10c2
Remove H2 database dependency
leingenm Nov 3, 2024
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
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