refactor-architecture #27
37
pom.xml
37
pom.xml
@ -5,14 +5,14 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.5.6</version>
|
<version>4.0.3</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
</parent>
|
</parent>
|
||||||
<groupId>com.magamochi</groupId>
|
<groupId>com.mangamochi</groupId>
|
||||||
<artifactId>mangamochi</artifactId>
|
<artifactId>mangamochi</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
<name>mangamochi</name>
|
<name>mangamochi</name>
|
||||||
<description>Demo project for Spring Boot</description>
|
<description/>
|
||||||
<url/>
|
<url/>
|
||||||
<licenses>
|
<licenses>
|
||||||
<license/>
|
<license/>
|
||||||
@ -27,7 +27,7 @@
|
|||||||
<url/>
|
<url/>
|
||||||
</scm>
|
</scm>
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>25</java.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@ -65,56 +65,49 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>software.amazon.awssdk</groupId>
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
<artifactId>s3</artifactId>
|
<artifactId>s3</artifactId>
|
||||||
<version>2.34.5</version>
|
<version>2.42.14</version>
|
||||||
<scope>compile</scope>
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
<version>2.8.13</version>
|
<version>3.0.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.cloud</groupId>
|
<groupId>org.springframework.cloud</groupId>
|
||||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||||
<version>4.3.0</version>
|
<version>5.0.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jsoup</groupId>
|
<groupId>org.jsoup</groupId>
|
||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
<version>1.21.2</version>
|
<version>1.22.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.hypersistence</groupId>
|
<groupId>io.hypersistence</groupId>
|
||||||
<artifactId>hypersistence-utils-hibernate-63</artifactId>
|
<artifactId>hypersistence-utils-hibernate-73</artifactId>
|
||||||
<version>3.11.0</version>
|
<version>3.15.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.google.guava</groupId>
|
<groupId>com.google.guava</groupId>
|
||||||
<artifactId>guava</artifactId>
|
<artifactId>guava</artifactId>
|
||||||
<version>33.5.0-jre</version>
|
<version>33.5.0-jre</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-api</artifactId>
|
<artifactId>jjwt-api</artifactId>
|
||||||
<version>0.13.0</version>
|
<version>0.13.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-impl</artifactId>
|
<artifactId>jjwt-impl</artifactId>
|
||||||
<version>0.13.0</version>
|
<version>0.13.0</version>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
<artifactId>jjwt-jackson</artifactId>
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
@ -125,11 +118,11 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- https://mvnrepository.com/artifact/io.github.resilience4j/resilience4j-spring-boot3 -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.resilience4j</groupId>
|
<groupId>org.apache.tika</groupId>
|
||||||
<artifactId>resilience4j-spring-boot3</artifactId>
|
<artifactId>tika-core</artifactId>
|
||||||
<version>2.3.0</version>
|
<version>3.2.3</version>
|
||||||
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
@ -162,7 +155,7 @@
|
|||||||
<plugin>
|
<plugin>
|
||||||
<groupId>com.diffplug.spotless</groupId>
|
<groupId>com.diffplug.spotless</groupId>
|
||||||
<artifactId>spotless-maven-plugin</artifactId>
|
<artifactId>spotless-maven-plugin</artifactId>
|
||||||
<version>2.46.1</version>
|
<version>3.3.0</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<java>
|
<java>
|
||||||
<googleJavaFormat/>
|
<googleJavaFormat/>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi;
|
package com.magamochi;
|
||||||
|
|
||||||
import org.springframework.amqp.rabbit.annotation.EnableRabbit;
|
import org.springframework.amqp.rabbit.annotation.EnableRabbit;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package com.magamochi.authentication.controller;
|
||||||
|
|
||||||
|
import com.magamochi.authentication.model.dto.AuthenticationRequestDTO;
|
||||||
|
import com.magamochi.authentication.model.dto.AuthenticationResponseDTO;
|
||||||
|
import com.magamochi.authentication.model.dto.RefreshTokenRequestDTO;
|
||||||
|
import com.magamochi.authentication.service.AuthenticationService;
|
||||||
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/authentication")
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthenticationController {
|
||||||
|
private final AuthenticationService authenticationService;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Authenticate an user",
|
||||||
|
description = "Authenticate an user with email and password.",
|
||||||
|
tags = {"Authentication"},
|
||||||
|
operationId = "login")
|
||||||
|
@PostMapping
|
||||||
|
public DefaultResponseDTO<AuthenticationResponseDTO> login(
|
||||||
|
@RequestBody AuthenticationRequestDTO authenticationRequestDTO) {
|
||||||
|
return DefaultResponseDTO.ok(authenticationService.authenticate(authenticationRequestDTO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Refresh authentication token",
|
||||||
|
description = "Refresh the authentication token",
|
||||||
|
tags = {"Authentication"},
|
||||||
|
operationId = "refreshAuthToken")
|
||||||
|
@PostMapping("/refresh")
|
||||||
|
public DefaultResponseDTO<AuthenticationResponseDTO> refreshAuthToken(
|
||||||
|
@RequestBody RefreshTokenRequestDTO authenticationRequestDTO) {
|
||||||
|
return DefaultResponseDTO.ok(authenticationService.refreshAuthToken(authenticationRequestDTO));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.authentication.model.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.authentication.model.dto;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.enumeration.UserRole;
|
import com.magamochi.user.model.enumeration.UserRole;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
public record AuthenticationResponseDTO(
|
public record AuthenticationResponseDTO(
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.authentication.model.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
@ -1,17 +1,16 @@
|
|||||||
package com.magamochi.mangamochi.security;
|
package com.magamochi.authentication.security;
|
||||||
|
|
||||||
import static java.util.Objects.isNull;
|
import static java.util.Objects.isNull;
|
||||||
import static java.util.Objects.nonNull;
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.service.CustomUserDetailsService;
|
import com.magamochi.user.service.CustomUserDetailsService;
|
||||||
import com.magamochi.mangamochi.util.JwtUtil;
|
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jspecify.annotations.NonNull;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
@ -26,7 +25,7 @@ public class JwtRequestFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(
|
protected void doFilterInternal(
|
||||||
HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain)
|
HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
final String authorizationHeader = request.getHeader("Authorization");
|
final String authorizationHeader = request.getHeader("Authorization");
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.util;
|
package com.magamochi.authentication.security;
|
||||||
|
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
@ -1,8 +1,6 @@
|
|||||||
package com.magamochi.mangamochi.config;
|
package com.magamochi.authentication.security;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.security.JwtRequestFilter;
|
import com.magamochi.user.service.CustomUserDetailsService;
|
||||||
import com.magamochi.mangamochi.service.CustomUserDetailsService;
|
|
||||||
import com.magamochi.mangamochi.util.JwtUtil;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@ -27,18 +25,17 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
|||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
private final CustomUserDetailsService customUserDetailsService;
|
||||||
private final CustomUserDetailsService userDetailsService;
|
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public JwtRequestFilter jwtRequestFilter() {
|
public JwtRequestFilter jwtRequestFilter() {
|
||||||
return new JwtRequestFilter(jwtUtil, userDetailsService);
|
return new JwtRequestFilter(jwtUtil, customUserDetailsService);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public DaoAuthenticationProvider authenticationProvider() {
|
public DaoAuthenticationProvider authenticationProvider() {
|
||||||
var authProvider = new DaoAuthenticationProvider(userDetailsService);
|
var authProvider = new DaoAuthenticationProvider(customUserDetailsService);
|
||||||
authProvider.setPasswordEncoder(passwordEncoder());
|
authProvider.setPasswordEncoder(passwordEncoder());
|
||||||
return authProvider;
|
return authProvider;
|
||||||
}
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.magamochi.authentication.service;
|
||||||
|
|
||||||
|
import com.magamochi.authentication.model.dto.AuthenticationRequestDTO;
|
||||||
|
import com.magamochi.authentication.model.dto.AuthenticationResponseDTO;
|
||||||
|
import com.magamochi.authentication.model.dto.RefreshTokenRequestDTO;
|
||||||
|
import com.magamochi.authentication.security.JwtUtil;
|
||||||
|
import com.magamochi.user.service.UserService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthenticationService {
|
||||||
|
private final AuthenticationManager authenticationManager;
|
||||||
|
|
||||||
|
private final UserDetailsService userDetailsService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
private final JwtUtil jwtUtil;
|
||||||
|
|
||||||
|
public AuthenticationResponseDTO authenticate(AuthenticationRequestDTO request) {
|
||||||
|
authenticationManager.authenticate(
|
||||||
|
new UsernamePasswordAuthenticationToken(request.email(), request.password()));
|
||||||
|
|
||||||
|
var userDetails = userDetailsService.loadUserByUsername(request.email());
|
||||||
|
|
||||||
|
var accessToken = jwtUtil.generateAccessToken(userDetails);
|
||||||
|
var refreshToken = jwtUtil.generateRefreshToken(userDetails);
|
||||||
|
|
||||||
|
var user = userService.find(userDetails.getUsername());
|
||||||
|
|
||||||
|
return new AuthenticationResponseDTO(
|
||||||
|
user.getId(),
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
userDetails.getUsername(),
|
||||||
|
user.getName(),
|
||||||
|
user.getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationResponseDTO refreshAuthToken(
|
||||||
|
RefreshTokenRequestDTO authenticationRequestDTO) {
|
||||||
|
var username = jwtUtil.extractUsernameFromRefreshToken(authenticationRequestDTO.refreshToken());
|
||||||
|
|
||||||
|
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
|
|
||||||
|
if (!jwtUtil.validateRefreshToken(authenticationRequestDTO.refreshToken(), userDetails)) {
|
||||||
|
throw new BadCredentialsException("Invalid refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAccessToken = jwtUtil.generateAccessToken(userDetails);
|
||||||
|
var newRefreshToken = jwtUtil.generateRefreshToken(userDetails);
|
||||||
|
|
||||||
|
var user = userService.find(userDetails.getUsername());
|
||||||
|
|
||||||
|
return new AuthenticationResponseDTO(
|
||||||
|
user.getId(),
|
||||||
|
newAccessToken,
|
||||||
|
newRefreshToken,
|
||||||
|
userDetails.getUsername(),
|
||||||
|
user.getName(),
|
||||||
|
user.getRole());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package com.magamochi.catalog.client;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
|
||||||
|
@FeignClient(name = "aniList", url = "https://graphql.anilist.co")
|
||||||
|
public interface AniListClient {
|
||||||
|
@PostMapping
|
||||||
|
MangaResponse getManga(@RequestBody GraphQLRequest request);
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
MangaSearchResponse searchManga(@RequestBody SearchGraphQLRequest request);
|
||||||
|
|
||||||
|
record GraphQLRequest(String query, Variables variables) {
|
||||||
|
public record Variables(Long id) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
record MangaResponse(Data data) {
|
||||||
|
public record Data(Manga Media) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
record SearchGraphQLRequest(String query, SearchVariables variables) {
|
||||||
|
public record SearchVariables(String search) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
record MangaSearchResponse(SearchData data) {
|
||||||
|
public record SearchData(@JsonProperty("Page") Page page) {}
|
||||||
|
|
||||||
|
public record Page(List<Manga> media) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
record Manga(
|
||||||
|
Long id,
|
||||||
|
Long idMal,
|
||||||
|
Title title,
|
||||||
|
String status,
|
||||||
|
String description,
|
||||||
|
Integer chapters,
|
||||||
|
Integer averageScore,
|
||||||
|
CoverImage coverImage,
|
||||||
|
List<String> genres,
|
||||||
|
FuzzyDate startDate,
|
||||||
|
FuzzyDate endDate,
|
||||||
|
StaffConnection staff) {
|
||||||
|
|
||||||
|
public record Title(
|
||||||
|
String romaji, String english, @JsonProperty("native") String nativeTitle) {}
|
||||||
|
|
||||||
|
public record CoverImage(String large) {}
|
||||||
|
|
||||||
|
public record FuzzyDate(Integer year, Integer month, Integer day) {}
|
||||||
|
|
||||||
|
public record StaffConnection(List<StaffEdge> edges) {
|
||||||
|
public record StaffEdge(String role, Staff node) {
|
||||||
|
public record Staff(Name name) {
|
||||||
|
public record Name(String full) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
package com.magamochi.mangamochi.client;
|
package com.magamochi.catalog.client;
|
||||||
|
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.cloud.openfeign.FeignClient;
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
@ -9,7 +8,6 @@ import org.springframework.web.bind.annotation.PathVariable;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
|
||||||
@FeignClient(name = "jikan", url = "https://api.jikan.moe/v4/manga")
|
@FeignClient(name = "jikan", url = "https://api.jikan.moe/v4/manga")
|
||||||
@Retry(name = "JikanRetry")
|
|
||||||
public interface JikanClient {
|
public interface JikanClient {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
SearchResponse mangaSearch(@RequestParam String q);
|
SearchResponse mangaSearch(@RequestParam String q);
|
||||||
@ -30,10 +28,10 @@ public interface JikanClient {
|
|||||||
String title,
|
String title,
|
||||||
List<String> title_synonyms,
|
List<String> title_synonyms,
|
||||||
String status,
|
String status,
|
||||||
boolean publishing,
|
Boolean publishing,
|
||||||
String synopsis,
|
String synopsis,
|
||||||
float score,
|
Float score,
|
||||||
int chapters,
|
Integer chapters,
|
||||||
PublishData published,
|
PublishData published,
|
||||||
List<AuthorData> authors,
|
List<AuthorData> authors,
|
||||||
List<GenreData> genres) {
|
List<GenreData> genres) {
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.magamochi.catalog.controller;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.dto.GenreDTO;
|
||||||
|
import com.magamochi.catalog.model.dto.MangaDTO;
|
||||||
|
import com.magamochi.catalog.model.dto.MangaListDTO;
|
||||||
|
import com.magamochi.catalog.model.dto.MangaListFilterDTO;
|
||||||
|
import com.magamochi.catalog.service.GenreService;
|
||||||
|
import com.magamochi.catalog.service.MangaService;
|
||||||
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springdoc.core.annotations.ParameterObject;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.web.PageableDefault;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/catalog")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CatalogController {
|
||||||
|
private final GenreService genreService;
|
||||||
|
private final MangaService mangaService;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get a list of manga genres",
|
||||||
|
description = "Retrieve a list of manga genres.",
|
||||||
|
tags = {"Catalog"},
|
||||||
|
operationId = "getGenres")
|
||||||
|
@GetMapping("/genres")
|
||||||
|
public DefaultResponseDTO<List<GenreDTO>> getGenres() {
|
||||||
|
return DefaultResponseDTO.ok(genreService.getGenres());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get a list of mangas",
|
||||||
|
description = "Retrieve a list of mangas with their details.",
|
||||||
|
tags = {"Catalog"},
|
||||||
|
operationId = "getMangas")
|
||||||
|
@GetMapping("/mangas")
|
||||||
|
public DefaultResponseDTO<Page<MangaListDTO>> getMangas(
|
||||||
|
@ParameterObject MangaListFilterDTO filterDTO,
|
||||||
|
@Parameter(hidden = true) @ParameterObject @PageableDefault Pageable pageable) {
|
||||||
|
return DefaultResponseDTO.ok(mangaService.get(filterDTO, pageable));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get the details of a manga",
|
||||||
|
description = "Get the details of a manga by its ID",
|
||||||
|
tags = {"Catalog"},
|
||||||
|
operationId = "getManga")
|
||||||
|
@GetMapping("/mangas/{mangaId}")
|
||||||
|
public DefaultResponseDTO<MangaDTO> getManga(@PathVariable Long mangaId) {
|
||||||
|
return DefaultResponseDTO.ok(mangaService.get(mangaId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package com.magamochi.catalog.controller;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.dto.MangaIngestReviewDTO;
|
||||||
|
import com.magamochi.catalog.service.MangaIngestReviewService;
|
||||||
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/catalog")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaIngestReviewController {
|
||||||
|
private final MangaIngestReviewService mangaIngestReviewService;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get list of pending manga ingest reviews",
|
||||||
|
description = "Get list of pending manga ingest reviews.",
|
||||||
|
tags = {"Manga Ingest Review"},
|
||||||
|
operationId = "getMangaIngestReviews")
|
||||||
|
@GetMapping("/ingest-reviews")
|
||||||
|
public DefaultResponseDTO<List<MangaIngestReviewDTO>> getMangaIngestReviews() {
|
||||||
|
return DefaultResponseDTO.ok(mangaIngestReviewService.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Delete pending manga ingest review",
|
||||||
|
description = "Delete pending manga ingest review by ID.",
|
||||||
|
tags = {"Manga Ingest Review"},
|
||||||
|
operationId = "deleteMangaIngestReview")
|
||||||
|
@DeleteMapping("/ingest-reviews/{id}")
|
||||||
|
public DefaultResponseDTO<Void> deleteMangaIngestReview(@PathVariable Long id) {
|
||||||
|
mangaIngestReviewService.deleteIngestReview(id);
|
||||||
|
|
||||||
|
return DefaultResponseDTO.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Resolve manga ingest review",
|
||||||
|
description = "Resolve manga ingest review by ID.",
|
||||||
|
tags = {"Manga Ingest Review"},
|
||||||
|
operationId = "resolveMangaIngestReview")
|
||||||
|
@PostMapping("/ingest-reviews")
|
||||||
|
public DefaultResponseDTO<Void> resolveMangaIngestReview(
|
||||||
|
@RequestParam Long id, @RequestParam String malId) {
|
||||||
|
mangaIngestReviewService.resolveImportReview(id, malId);
|
||||||
|
|
||||||
|
return DefaultResponseDTO.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.catalog.model.dto;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.Genre;
|
import com.magamochi.catalog.model.entity.Genre;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
@ -1,12 +1,12 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.catalog.model.dto;
|
||||||
|
|
||||||
import static java.util.Objects.isNull;
|
import static java.util.Objects.isNull;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.Manga;
|
import com.magamochi.catalog.model.entity.Manga;
|
||||||
import com.magamochi.mangamochi.model.entity.MangaAlternativeTitle;
|
import com.magamochi.catalog.model.entity.MangaAlternativeTitle;
|
||||||
import com.magamochi.mangamochi.model.entity.MangaChapter;
|
import com.magamochi.catalog.model.entity.MangaContentProvider;
|
||||||
import com.magamochi.mangamochi.model.entity.MangaProvider;
|
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
||||||
import com.magamochi.mangamochi.model.enumeration.ProviderStatus;
|
import com.magamochi.content.model.entity.MangaContent;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
@ -16,7 +16,7 @@ public record MangaDTO(
|
|||||||
@NotNull Long id,
|
@NotNull Long id,
|
||||||
@NotBlank String title,
|
@NotBlank String title,
|
||||||
String coverImageKey,
|
String coverImageKey,
|
||||||
String status,
|
MangaStatus status,
|
||||||
OffsetDateTime publishedFrom,
|
OffsetDateTime publishedFrom,
|
||||||
OffsetDateTime publishedTo,
|
OffsetDateTime publishedTo,
|
||||||
String synopsis,
|
String synopsis,
|
||||||
@ -33,43 +33,43 @@ public record MangaDTO(
|
|||||||
return new MangaDTO(
|
return new MangaDTO(
|
||||||
manga.getId(),
|
manga.getId(),
|
||||||
manga.getTitle(),
|
manga.getTitle(),
|
||||||
isNull(manga.getCoverImage()) ? null : manga.getCoverImage().getFileKey(),
|
isNull(manga.getCoverImage()) ? null : manga.getCoverImage().getObjectKey(),
|
||||||
manga.getStatus(),
|
manga.getStatus(),
|
||||||
manga.getPublishedFrom(),
|
manga.getPublishedFrom(),
|
||||||
manga.getPublishedTo(),
|
manga.getPublishedTo(),
|
||||||
manga.getSynopsis(),
|
manga.getSynopsis(),
|
||||||
manga.getMangaProviders().size(),
|
manga.getMangaContentProviders().size(),
|
||||||
manga.getAlternativeTitles().stream().map(MangaAlternativeTitle::getTitle).toList(),
|
manga.getAlternativeTitles().stream().map(MangaAlternativeTitle::getTitle).toList(),
|
||||||
manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(),
|
manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(),
|
||||||
manga.getMangaAuthors().stream()
|
manga.getMangaAuthors().stream()
|
||||||
.map(mangaAuthor -> mangaAuthor.getAuthor().getName())
|
.map(mangaAuthor -> mangaAuthor.getAuthor().getName())
|
||||||
.toList(),
|
.toList(),
|
||||||
manga.getScore(),
|
manga.getScore(),
|
||||||
manga.getMangaProviders().stream().map(MangaProviderDTO::from).toList(),
|
manga.getMangaContentProviders().stream().map(MangaProviderDTO::from).toList(),
|
||||||
manga.getChapterCount(),
|
manga.getChapterCount(),
|
||||||
favorite,
|
favorite,
|
||||||
following);
|
following);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record MangaProviderDTO(
|
public record MangaProviderDTO(
|
||||||
@NotNull long id,
|
long id,
|
||||||
@NotBlank String providerName,
|
@NotBlank String providerName,
|
||||||
@NotNull ProviderStatus providerStatus,
|
boolean active,
|
||||||
@NotNull Integer chaptersAvailable,
|
@NotNull Integer chaptersAvailable,
|
||||||
@NotNull Integer chaptersDownloaded,
|
@NotNull Integer chaptersDownloaded,
|
||||||
@NotNull Boolean supportsChapterFetch) {
|
@NotNull Boolean supportsChapterFetch) {
|
||||||
public static MangaProviderDTO from(MangaProvider mangaProvider) {
|
public static MangaProviderDTO from(MangaContentProvider mangaContentProvider) {
|
||||||
var chapters = mangaProvider.getMangaChapters();
|
var chapters = mangaContentProvider.getMangaContents();
|
||||||
var chaptersAvailable = chapters.size();
|
var chaptersAvailable = chapters.size();
|
||||||
var chaptersDownloaded = (int) chapters.stream().filter(MangaChapter::getDownloaded).count();
|
var chaptersDownloaded = (int) chapters.stream().filter(MangaContent::getDownloaded).count();
|
||||||
|
|
||||||
return new MangaProviderDTO(
|
return new MangaProviderDTO(
|
||||||
mangaProvider.getId(),
|
mangaContentProvider.getId(),
|
||||||
mangaProvider.getProvider().getName(),
|
mangaContentProvider.getContentProvider().getName(),
|
||||||
mangaProvider.getProvider().getStatus(),
|
mangaContentProvider.getContentProvider().isActive(),
|
||||||
chaptersAvailable,
|
chaptersAvailable,
|
||||||
chaptersDownloaded,
|
chaptersDownloaded,
|
||||||
mangaProvider.getProvider().getSupportsChapterFetch());
|
mangaContentProvider.getContentProvider().getSupportsContentFetch());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.magamochi.catalog.model.dto;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Builder;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record MangaDataDTO(
|
||||||
|
String title,
|
||||||
|
String synopsis,
|
||||||
|
MangaStatus status,
|
||||||
|
Double score,
|
||||||
|
OffsetDateTime publishedFrom,
|
||||||
|
OffsetDateTime publishedTo,
|
||||||
|
Integer chapterCount,
|
||||||
|
List<String> authors,
|
||||||
|
List<String> genres,
|
||||||
|
List<String> alternativeTitles,
|
||||||
|
String coverImageUrl) {}
|
||||||
@ -1,22 +1,22 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.catalog.model.dto;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.MangaImportReview;
|
import com.magamochi.catalog.model.entity.MangaIngestReview;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record ImportReviewDTO(
|
public record MangaIngestReviewDTO(
|
||||||
@NotNull Long id,
|
@NotNull Long id,
|
||||||
@NotBlank String title,
|
@NotBlank String title,
|
||||||
@NotBlank String providerName,
|
@NotBlank String contentProviderName,
|
||||||
String externalUrl,
|
String externalUrl,
|
||||||
@NotBlank String reason,
|
@NotBlank String reason,
|
||||||
@NotNull Instant createdAt) {
|
@NotNull Instant createdAt) {
|
||||||
public static ImportReviewDTO from(MangaImportReview review) {
|
public static MangaIngestReviewDTO from(MangaIngestReview review) {
|
||||||
return new ImportReviewDTO(
|
return new MangaIngestReviewDTO(
|
||||||
review.getId(),
|
review.getId(),
|
||||||
review.getTitle(),
|
review.getMangaTitle(),
|
||||||
review.getProvider().getName(),
|
review.getContentProvider().getName(),
|
||||||
review.getUrl(),
|
review.getUrl(),
|
||||||
"Title match not found",
|
"Title match not found",
|
||||||
review.getCreatedAt());
|
review.getCreatedAt());
|
||||||
@ -1,8 +1,9 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.catalog.model.dto;
|
||||||
|
|
||||||
import static java.util.Objects.nonNull;
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.Manga;
|
import com.magamochi.catalog.model.entity.Manga;
|
||||||
|
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
@ -12,7 +13,7 @@ public record MangaListDTO(
|
|||||||
@NotNull Long id,
|
@NotNull Long id,
|
||||||
@NotBlank String title,
|
@NotBlank String title,
|
||||||
String coverImageKey,
|
String coverImageKey,
|
||||||
String status,
|
MangaStatus status,
|
||||||
OffsetDateTime publishedFrom,
|
OffsetDateTime publishedFrom,
|
||||||
OffsetDateTime publishedTo,
|
OffsetDateTime publishedTo,
|
||||||
Integer providerCount,
|
Integer providerCount,
|
||||||
@ -24,11 +25,11 @@ public record MangaListDTO(
|
|||||||
return new MangaListDTO(
|
return new MangaListDTO(
|
||||||
manga.getId(),
|
manga.getId(),
|
||||||
manga.getTitle(),
|
manga.getTitle(),
|
||||||
nonNull(manga.getCoverImage()) ? manga.getCoverImage().getFileKey() : null,
|
nonNull(manga.getCoverImage()) ? manga.getCoverImage().getObjectKey() : null,
|
||||||
manga.getStatus(),
|
manga.getStatus(),
|
||||||
manga.getPublishedFrom(),
|
manga.getPublishedFrom(),
|
||||||
manga.getPublishedTo(),
|
manga.getPublishedTo(),
|
||||||
manga.getMangaProviders().size(),
|
manga.getMangaContentProviders().size(),
|
||||||
manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(),
|
manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(),
|
||||||
manga.getMangaAuthors().stream()
|
manga.getMangaAuthors().stream()
|
||||||
.map(mangaAuthor -> mangaAuthor.getAuthor().getName())
|
.map(mangaAuthor -> mangaAuthor.getAuthor().getName())
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.catalog.model.dto;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.catalog.model.entity;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@ -19,8 +19,6 @@ public class Author {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
private Long malId;
|
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@CreationTimestamp private Instant createdAt;
|
@CreationTimestamp private Instant createdAt;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.catalog.model.entity;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -16,8 +16,6 @@ public class Genre {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
private Long malId;
|
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@OneToMany(mappedBy = "genre")
|
@OneToMany(mappedBy = "genre")
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.catalog.model.entity;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
@ -1,5 +1,9 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.catalog.model.entity;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.enumeration.MangaState;
|
||||||
|
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
||||||
|
import com.magamochi.image.model.entity.Image;
|
||||||
|
import com.magamochi.model.entity.UserFavoriteManga;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
@ -26,16 +30,13 @@ public class Manga {
|
|||||||
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
private String status;
|
@Enumerated(EnumType.STRING)
|
||||||
|
private MangaStatus status;
|
||||||
|
|
||||||
private String synopsis;
|
private String synopsis;
|
||||||
|
|
||||||
@CreationTimestamp private Instant createdAt;
|
|
||||||
|
|
||||||
@UpdateTimestamp private Instant updatedAt;
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "manga")
|
@OneToMany(mappedBy = "manga")
|
||||||
private List<MangaProvider> mangaProviders;
|
private List<MangaContentProvider> mangaContentProviders;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "cover_image_id")
|
@JoinColumn(name = "cover_image_id")
|
||||||
@ -47,6 +48,18 @@ public class Manga {
|
|||||||
|
|
||||||
private OffsetDateTime publishedTo;
|
private OffsetDateTime publishedTo;
|
||||||
|
|
||||||
|
@Builder.Default private Integer chapterCount = 0;
|
||||||
|
|
||||||
|
@Builder.Default private Boolean follow = false;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Builder.Default
|
||||||
|
private MangaState state = MangaState.PENDING;
|
||||||
|
|
||||||
|
@CreationTimestamp private Instant createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp private Instant updatedAt;
|
||||||
|
|
||||||
@OneToMany(mappedBy = "manga")
|
@OneToMany(mappedBy = "manga")
|
||||||
private List<MangaAuthor> mangaAuthors;
|
private List<MangaAuthor> mangaAuthors;
|
||||||
|
|
||||||
@ -58,8 +71,4 @@ public class Manga {
|
|||||||
|
|
||||||
@OneToMany(mappedBy = "manga")
|
@OneToMany(mappedBy = "manga")
|
||||||
private List<MangaAlternativeTitle> alternativeTitles;
|
private List<MangaAlternativeTitle> alternativeTitles;
|
||||||
|
|
||||||
@Builder.Default private Integer chapterCount = 0;
|
|
||||||
|
|
||||||
@Builder.Default private Boolean follow = false;
|
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.catalog.model.entity;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.catalog.model.entity;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
@ -1,5 +1,7 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.catalog.model.entity;
|
||||||
|
|
||||||
|
import com.magamochi.content.model.entity.MangaContent;
|
||||||
|
import com.magamochi.ingestion.model.entity.ContentProvider;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -8,13 +10,13 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "manga_provider")
|
@Table(name = "manga_content_provider")
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
public class MangaProvider {
|
public class MangaContentProvider {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
@ -24,15 +26,15 @@ public class MangaProvider {
|
|||||||
private Manga manga;
|
private Manga manga;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "provider_id", nullable = false)
|
@JoinColumn(name = "content_provider_id", nullable = false)
|
||||||
private Provider provider;
|
private ContentProvider contentProvider;
|
||||||
|
|
||||||
private String mangaTitle;
|
private String mangaTitle;
|
||||||
|
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
@OneToMany(mappedBy = "mangaProvider")
|
@OneToMany(mappedBy = "mangaContentProvider")
|
||||||
List<MangaChapter> mangaChapters;
|
List<MangaContent> mangaContents;
|
||||||
|
|
||||||
@CreationTimestamp private Instant createdAt;
|
@CreationTimestamp private Instant createdAt;
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.catalog.model.entity;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
@ -1,29 +1,30 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.catalog.model.entity;
|
||||||
|
|
||||||
|
import com.magamochi.ingestion.model.entity.ContentProvider;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "manga_import_reviews")
|
@Table(name = "manga_ingest_reviews")
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
public class MangaImportReview {
|
public class MangaIngestReview {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
private String title;
|
private String mangaTitle;
|
||||||
|
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "provider_id")
|
@JoinColumn(name = "content_provider_id")
|
||||||
private Provider provider;
|
private ContentProvider contentProvider;
|
||||||
|
|
||||||
@CreationTimestamp private Instant createdAt;
|
@CreationTimestamp private Instant createdAt;
|
||||||
}
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.magamochi.catalog.model.enumeration;
|
||||||
|
|
||||||
|
public enum MangaState {
|
||||||
|
PENDING,
|
||||||
|
AVAILABLE,
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.enumeration;
|
package com.magamochi.catalog.model.enumeration;
|
||||||
|
|
||||||
public enum MangaStatus {
|
public enum MangaStatus {
|
||||||
ONGOING,
|
ONGOING,
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.magamochi.catalog.model.repository;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.entity.Author;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface AuthorRepository extends JpaRepository<Author, Long> {}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.magamochi.catalog.model.repository;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.entity.Genre;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface GenreRepository extends JpaRepository<Genre, Long> {}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package com.magamochi.mangamochi.model.repository;
|
package com.magamochi.catalog.model.repository;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.Language;
|
import com.magamochi.catalog.model.entity.Language;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package com.magamochi.mangamochi.model.repository;
|
package com.magamochi.catalog.model.repository;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.MangaAlternativeTitle;
|
import com.magamochi.catalog.model.entity.MangaAlternativeTitle;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
public interface MangaAlternativeTitlesRepository
|
public interface MangaAlternativeTitlesRepository
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.magamochi.mangamochi.model.repository;
|
package com.magamochi.catalog.model.repository;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.Author;
|
import com.magamochi.catalog.model.entity.Author;
|
||||||
import com.magamochi.mangamochi.model.entity.Manga;
|
import com.magamochi.catalog.model.entity.Manga;
|
||||||
import com.magamochi.mangamochi.model.entity.MangaAuthor;
|
import com.magamochi.catalog.model.entity.MangaAuthor;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.magamochi.catalog.model.repository;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.entity.MangaContentProvider;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface MangaContentProviderRepository extends JpaRepository<MangaContentProvider, Long> {
|
||||||
|
boolean existsByMangaTitleIgnoreCaseAndContentProvider_Id(
|
||||||
|
@NotBlank String mangaTitle, long contentProviderId);
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
package com.magamochi.mangamochi.model.repository;
|
package com.magamochi.catalog.model.repository;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.*;
|
import com.magamochi.catalog.model.entity.Genre;
|
||||||
|
import com.magamochi.catalog.model.entity.Manga;
|
||||||
|
import com.magamochi.catalog.model.entity.MangaGenre;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.magamochi.catalog.model.repository;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.entity.MangaIngestReview;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface MangaIngestReviewRepository extends JpaRepository<MangaIngestReview, Long> {
|
||||||
|
boolean existsByMangaTitleIgnoreCaseAndContentProvider_Id(String mangaTitle, long providerId);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package com.magamochi.mangamochi.model.repository;
|
package com.magamochi.catalog.model.repository;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.Manga;
|
import com.magamochi.catalog.model.entity.Manga;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package com.magamochi.catalog.queue.command;
|
||||||
|
|
||||||
|
public record MangaUpdateCommand(long mangaId) {}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.magamochi.catalog.queue.consumer;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.service.MangaUpdateService;
|
||||||
|
import com.magamochi.common.queue.command.ImageUpdateCommand;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaCoverUpdateConsumer {
|
||||||
|
private final MangaUpdateService mangaUpdateService;
|
||||||
|
|
||||||
|
@RabbitListener(queues = "${queues.manga-cover-update}")
|
||||||
|
public void receiveImageUpdateCommand(ImageUpdateCommand command) {
|
||||||
|
log.info("Received manga cover image update command: {}", command);
|
||||||
|
mangaUpdateService.updateMangaCoverImage(command.entityId(), command.imageId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.magamochi.catalog.queue.consumer;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.service.MangaIngestService;
|
||||||
|
import com.magamochi.common.queue.command.MangaIngestCommand;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaIngestConsumer {
|
||||||
|
private final MangaIngestService mangaIngestService;
|
||||||
|
|
||||||
|
@RabbitListener(queues = "${queues.manga-ingest}")
|
||||||
|
public void receiveMangaIngestCommand(MangaIngestCommand command) {
|
||||||
|
log.info("Received manga ingest command: {}", command);
|
||||||
|
mangaIngestService.ingestManga(command.providerId(), command.mangaTitle(), command.url());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.magamochi.catalog.queue.consumer;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.queue.command.MangaUpdateCommand;
|
||||||
|
import com.magamochi.catalog.service.MangaUpdateService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaUpdateConsumer {
|
||||||
|
private final MangaUpdateService mangaUpdateService;
|
||||||
|
|
||||||
|
@RabbitListener(queues = "${queues.manga-update}")
|
||||||
|
public void receiveMangaUpdateCommand(MangaUpdateCommand command) {
|
||||||
|
log.info("Received manga update command: {}", command);
|
||||||
|
mangaUpdateService.update(command.mangaId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.magamochi.catalog.queue.producer;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.queue.command.MangaUpdateCommand;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaUpdateProducer {
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
|
||||||
|
@Value("${queues.manga-update}")
|
||||||
|
private String mangaUpdateQueue;
|
||||||
|
|
||||||
|
public void sendMangaUpdateCommand(MangaUpdateCommand command) {
|
||||||
|
rabbitTemplate.convertAndSend(mangaUpdateQueue, command);
|
||||||
|
log.info("Sent manga update command: {}", command);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/main/java/com/magamochi/catalog/service/AniListService.java
Normal file
179
src/main/java/com/magamochi/catalog/service/AniListService.java
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.isNull;
|
||||||
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
|
import com.google.common.util.concurrent.RateLimiter;
|
||||||
|
import com.magamochi.catalog.client.AniListClient;
|
||||||
|
import com.magamochi.catalog.model.dto.MangaDataDTO;
|
||||||
|
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
||||||
|
import com.magamochi.catalog.util.DoubleUtil;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.jspecify.annotations.NonNull;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AniListService {
|
||||||
|
private final AniListClient aniListClient;
|
||||||
|
private final RateLimiter aniListRateLimiter;
|
||||||
|
|
||||||
|
public Map<String, AniListClient.Manga> searchMangaByTitle(String title) {
|
||||||
|
var request = getSearchGraphQLRequest(title);
|
||||||
|
|
||||||
|
aniListRateLimiter.acquire();
|
||||||
|
var response = aniListClient.searchManga(request);
|
||||||
|
|
||||||
|
if (nonNull(response) && nonNull(response.data()) && nonNull(response.data().page())) {
|
||||||
|
return response.data().page().media().stream()
|
||||||
|
.flatMap(
|
||||||
|
manga ->
|
||||||
|
Stream.of(
|
||||||
|
manga.title().romaji(),
|
||||||
|
manga.title().english(),
|
||||||
|
manga.title().nativeTitle())
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.filter(t -> !t.isBlank())
|
||||||
|
.map(titleString -> Map.entry(titleString, manga)))
|
||||||
|
.collect(
|
||||||
|
Collectors.toMap(
|
||||||
|
Map.Entry::getKey, Map.Entry::getValue, (existingManga, manga) -> existingManga));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MangaDataDTO getMangaDataById(Long aniListId) {
|
||||||
|
var request = getGraphQLRequest(aniListId);
|
||||||
|
|
||||||
|
aniListRateLimiter.acquire();
|
||||||
|
var media = aniListClient.getManga(request).data().Media();
|
||||||
|
|
||||||
|
var authors =
|
||||||
|
media.staff().edges().stream()
|
||||||
|
.filter(edge -> isAuthorRole(edge.role()))
|
||||||
|
.map(edge -> edge.node().name().full())
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
boolean hasRomajiTitle = nonNull(media.title().romaji());
|
||||||
|
return MangaDataDTO.builder()
|
||||||
|
.title(hasRomajiTitle ? media.title().romaji() : media.title().english())
|
||||||
|
.score(
|
||||||
|
nonNull(media.averageScore())
|
||||||
|
? DoubleUtil.round((double) media.averageScore() / 10, 2)
|
||||||
|
: 0)
|
||||||
|
.synopsis(media.description())
|
||||||
|
.chapterCount(media.chapters())
|
||||||
|
.publishedFrom(convertFuzzyDate(media.startDate()))
|
||||||
|
.publishedTo(convertFuzzyDate(media.endDate()))
|
||||||
|
.authors(authors)
|
||||||
|
.genres(media.genres())
|
||||||
|
// TODO: improve this
|
||||||
|
.alternativeTitles(
|
||||||
|
hasRomajiTitle
|
||||||
|
? nonNull(media.title().english())
|
||||||
|
? List.of(media.title().english(), media.title().nativeTitle())
|
||||||
|
: List.of(media.title().nativeTitle())
|
||||||
|
: List.of(media.title().nativeTitle()))
|
||||||
|
.coverImageUrl(media.coverImage().large())
|
||||||
|
.status(mapStatus(media.status()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AniListClient.@NonNull GraphQLRequest getGraphQLRequest(Long aniListId) {
|
||||||
|
var query =
|
||||||
|
"""
|
||||||
|
query ($id: Int) {
|
||||||
|
Media (id: $id, type: MANGA) {
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
english
|
||||||
|
native
|
||||||
|
}
|
||||||
|
startDate { year month day }
|
||||||
|
endDate { year month day }
|
||||||
|
description
|
||||||
|
status
|
||||||
|
averageScore
|
||||||
|
chapters
|
||||||
|
coverImage { large }
|
||||||
|
genres
|
||||||
|
staff {
|
||||||
|
edges {
|
||||||
|
role
|
||||||
|
node {
|
||||||
|
name {
|
||||||
|
full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
return new AniListClient.GraphQLRequest(
|
||||||
|
query, new AniListClient.GraphQLRequest.Variables(aniListId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AniListClient.@NonNull SearchGraphQLRequest getSearchGraphQLRequest(String title) {
|
||||||
|
var query =
|
||||||
|
"""
|
||||||
|
query ($search: String) {
|
||||||
|
Page(page: 1, perPage: 10) {
|
||||||
|
media(search: $search, type: MANGA) {
|
||||||
|
id
|
||||||
|
idMal
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
english
|
||||||
|
native
|
||||||
|
}
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var variables = new AniListClient.SearchGraphQLRequest.SearchVariables(title);
|
||||||
|
return new AniListClient.SearchGraphQLRequest(query, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime convertFuzzyDate(AniListClient.Manga.FuzzyDate date) {
|
||||||
|
if (isNull(date) || isNull(date.year())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return OffsetDateTime.of(
|
||||||
|
date.year(),
|
||||||
|
isNull(date.month()) ? 1 : date.month(),
|
||||||
|
isNull(date.day()) ? 1 : date.day(),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MangaStatus mapStatus(String aniListStatus) {
|
||||||
|
return switch (aniListStatus.toLowerCase()) {
|
||||||
|
case "releasing" -> MangaStatus.ONGOING;
|
||||||
|
case "finished" -> MangaStatus.COMPLETED;
|
||||||
|
default -> MangaStatus.UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isAuthorRole(String role) {
|
||||||
|
return role.equalsIgnoreCase("Story & Art")
|
||||||
|
|| role.equalsIgnoreCase("Story")
|
||||||
|
|| role.equalsIgnoreCase("Art");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
package com.magamochi.mangamochi.service;
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.dto.GenreDTO;
|
import com.magamochi.catalog.model.dto.GenreDTO;
|
||||||
import com.magamochi.mangamochi.model.repository.GenreRepository;
|
import com.magamochi.catalog.model.repository.GenreRepository;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.magamochi.mangamochi.service;
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.exception.NotFoundException;
|
import com.magamochi.catalog.model.entity.Language;
|
||||||
import com.magamochi.mangamochi.model.entity.Language;
|
import com.magamochi.catalog.model.repository.LanguageRepository;
|
||||||
import com.magamochi.mangamochi.model.repository.LanguageRepository;
|
import com.magamochi.common.exception.NotFoundException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@ -11,7 +11,7 @@ import org.springframework.stereotype.Service;
|
|||||||
public class LanguageService {
|
public class LanguageService {
|
||||||
public final LanguageRepository languageRepository;
|
public final LanguageRepository languageRepository;
|
||||||
|
|
||||||
public Language getOrThrow(String code) {
|
public Language find(String code) {
|
||||||
return languageRepository
|
return languageRepository
|
||||||
.findByCodeIgnoreCase(code)
|
.findByCodeIgnoreCase(code)
|
||||||
.orElseThrow(() -> new NotFoundException("Language with code " + code + " not found"));
|
.orElseThrow(() -> new NotFoundException("Language with code " + code + " not found"));
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.entity.MangaContentProvider;
|
||||||
|
import com.magamochi.catalog.model.repository.MangaContentProviderRepository;
|
||||||
|
import com.magamochi.common.exception.NotFoundException;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaContentProviderService {
|
||||||
|
private final MangaContentProviderRepository mangaContentProviderRepository;
|
||||||
|
|
||||||
|
public MangaContentProvider find(long mangaContentProviderId) {
|
||||||
|
return mangaContentProviderRepository
|
||||||
|
.findById(mangaContentProviderId)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new NotFoundException(
|
||||||
|
"MangaContentProvider not found - ID: " + mangaContentProviderId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.dto.MangaIngestReviewDTO;
|
||||||
|
import com.magamochi.catalog.model.entity.MangaIngestReview;
|
||||||
|
import com.magamochi.catalog.model.repository.MangaIngestReviewRepository;
|
||||||
|
import com.magamochi.common.exception.NotFoundException;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.NotImplementedException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaIngestReviewService {
|
||||||
|
private final MangaIngestReviewRepository mangaIngestReviewRepository;
|
||||||
|
|
||||||
|
public List<MangaIngestReviewDTO> get() {
|
||||||
|
return mangaIngestReviewRepository.findAll().stream().map(MangaIngestReviewDTO::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteIngestReview(Long id) {
|
||||||
|
var importReview = getImportReviewThrowIfNotFound(id);
|
||||||
|
|
||||||
|
mangaIngestReviewRepository.delete(importReview);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resolveImportReview(Long id, String malId) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
// var importReview = getImportReviewThrowIfNotFound(id);
|
||||||
|
//
|
||||||
|
// jikanRateLimiter.acquire();
|
||||||
|
// var jikanResult = jikanClient.getMangaById(Long.parseLong(malId)).data();
|
||||||
|
//
|
||||||
|
// if (isNull(jikanResult)) {
|
||||||
|
// throw new NotFoundException("MyAnimeList manga not found for ID: " + id);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// var manga =
|
||||||
|
// mangaRepository
|
||||||
|
// .findByTitleIgnoreCase(jikanResult.title())
|
||||||
|
// .orElseGet(
|
||||||
|
// () ->
|
||||||
|
// mangaRepository.save(
|
||||||
|
// Manga.builder()
|
||||||
|
// .title(jikanResult.title())
|
||||||
|
// .malId(Long.parseLong(malId))
|
||||||
|
// .build()));
|
||||||
|
//
|
||||||
|
// if (!mangaContentProviderRepository.existsByMangaAndContentProviderAndUrlIgnoreCase(
|
||||||
|
// manga, importReview.getContentProvider(), importReview.getUrl())) {
|
||||||
|
// mangaContentProviderRepository.save(
|
||||||
|
// MangaContentProvider.builder()
|
||||||
|
// .manga(manga)
|
||||||
|
// .mangaTitle(importReview.getMangaTitle())
|
||||||
|
// .contentProvider(importReview.getContentProvider())
|
||||||
|
// .url(importReview.getUrl())
|
||||||
|
// .build());
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// mangaIngestReviewRepository.delete(importReview);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MangaIngestReview getImportReviewThrowIfNotFound(Long id) {
|
||||||
|
return mangaIngestReviewRepository
|
||||||
|
.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Import review not found for ID: " + id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.isNull;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.entity.MangaContentProvider;
|
||||||
|
import com.magamochi.catalog.model.entity.MangaIngestReview;
|
||||||
|
import com.magamochi.catalog.model.repository.MangaContentProviderRepository;
|
||||||
|
import com.magamochi.catalog.model.repository.MangaIngestReviewRepository;
|
||||||
|
import com.magamochi.ingestion.service.ContentProviderService;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaIngestService {
|
||||||
|
private final ContentProviderService contentProviderService;
|
||||||
|
private final MangaResolutionService mangaResolutionService;
|
||||||
|
|
||||||
|
private final MangaIngestReviewRepository mangaIngestReviewRepository;
|
||||||
|
private final MangaContentProviderRepository mangaContentProviderRepository;
|
||||||
|
|
||||||
|
public void ingestManga(
|
||||||
|
long contentProviderId, @NotBlank String mangaTitle, @NotBlank String url) {
|
||||||
|
log.info(
|
||||||
|
"Ingesting manga with mangaTitle '{}' from provider {}", mangaTitle, contentProviderId);
|
||||||
|
|
||||||
|
if (mangaContentProviderRepository.existsByMangaTitleIgnoreCaseAndContentProvider_Id(
|
||||||
|
mangaTitle, contentProviderId)) {
|
||||||
|
log.info(
|
||||||
|
"Manga with mangaTitle '{}' already exists for provider '{}', skipping ingest",
|
||||||
|
mangaTitle,
|
||||||
|
contentProviderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var manga = mangaResolutionService.findOrCreateManga(mangaTitle);
|
||||||
|
if (isNull(manga)) {
|
||||||
|
createMangaIngestReview(mangaTitle, url, contentProviderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var contentProvider = contentProviderService.find(contentProviderId);
|
||||||
|
|
||||||
|
mangaContentProviderRepository.save(
|
||||||
|
MangaContentProvider.builder()
|
||||||
|
.manga(manga)
|
||||||
|
.mangaTitle(mangaTitle)
|
||||||
|
.contentProvider(contentProvider)
|
||||||
|
.url(url)
|
||||||
|
.build());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error(
|
||||||
|
"Failed to ingest manga with mangaTitle '{}' from provider '{}'",
|
||||||
|
mangaTitle,
|
||||||
|
contentProviderId,
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Successfully ingested manga with mangaTitle '{}' from provider {}",
|
||||||
|
mangaTitle,
|
||||||
|
contentProviderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createMangaIngestReview(String mangaTitle, String url, long contentProviderId) {
|
||||||
|
log.info(
|
||||||
|
"Creating manga ingest review for manga with mangaTitle '{}' from provider {}",
|
||||||
|
mangaTitle,
|
||||||
|
contentProviderId);
|
||||||
|
|
||||||
|
if (mangaIngestReviewRepository.existsByMangaTitleIgnoreCaseAndContentProvider_Id(
|
||||||
|
mangaTitle, contentProviderId)) {
|
||||||
|
log.info(
|
||||||
|
"Manga ingest review already exists for manga with mangaTitle '{}' and provider {}, skipping review creation",
|
||||||
|
mangaTitle,
|
||||||
|
contentProviderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var contentProvider = contentProviderService.find(contentProviderId);
|
||||||
|
|
||||||
|
mangaIngestReviewRepository.save(
|
||||||
|
MangaIngestReview.builder()
|
||||||
|
.mangaTitle(mangaTitle)
|
||||||
|
.url(url)
|
||||||
|
.contentProvider(contentProvider)
|
||||||
|
.build());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error(
|
||||||
|
"Failed to create manga ingest review for manga with mangaTitle '{}' from provider {}",
|
||||||
|
mangaTitle,
|
||||||
|
contentProviderId,
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.entity.Manga;
|
||||||
|
import com.magamochi.catalog.model.repository.MangaRepository;
|
||||||
|
import com.magamochi.catalog.queue.command.MangaUpdateCommand;
|
||||||
|
import com.magamochi.catalog.queue.producer.MangaUpdateProducer;
|
||||||
|
import java.util.Optional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaResolutionService {
|
||||||
|
private final AniListService aniListService;
|
||||||
|
private final MyAnimeListService myAnimeListService;
|
||||||
|
private final TitleMatcherService titleMatcherService;
|
||||||
|
|
||||||
|
private final MangaUpdateProducer mangaUpdateProducer;
|
||||||
|
|
||||||
|
private final MangaRepository mangaRepository;
|
||||||
|
|
||||||
|
public Manga findOrCreateManga(String searchTitle) {
|
||||||
|
var existingManga = mangaRepository.findByTitleIgnoreCase(searchTitle);
|
||||||
|
if (existingManga.isPresent()) {
|
||||||
|
return existingManga.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
var aniListResult = searchMangaOnAniList(searchTitle);
|
||||||
|
var malResult = searchMangaOnMyAnimeList(searchTitle);
|
||||||
|
|
||||||
|
if (aniListResult.isEmpty() && malResult.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonicalTitle =
|
||||||
|
aniListResult
|
||||||
|
.map(ProviderResult::title)
|
||||||
|
.orElseGet(() -> malResult.map(ProviderResult::title).orElse(searchTitle));
|
||||||
|
|
||||||
|
var aniListId = aniListResult.map(ProviderResult::externalId).orElse(null);
|
||||||
|
var malId = malResult.map(ProviderResult::externalId).orElse(null);
|
||||||
|
|
||||||
|
return findOrCreateManga(canonicalTitle, aniListId, malId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<ProviderResult> searchMangaOnAniList(String title) {
|
||||||
|
var searchResults = aniListService.searchMangaByTitle(title);
|
||||||
|
if (searchResults.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchResponse =
|
||||||
|
titleMatcherService.findBestMatch(
|
||||||
|
TitleMatcherService.TitleMatchRequest.builder()
|
||||||
|
.title(title)
|
||||||
|
.options(searchResults.keySet())
|
||||||
|
.build());
|
||||||
|
|
||||||
|
if (!matchResponse.matchFound()) {
|
||||||
|
log.warn("No title match found for manga with title {} on AniList", title);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchedManga = searchResults.get(matchResponse.bestMatch());
|
||||||
|
|
||||||
|
var bestTitle =
|
||||||
|
nonNull(matchedManga.title().romaji())
|
||||||
|
? matchedManga.title().romaji()
|
||||||
|
: matchedManga.title().english();
|
||||||
|
|
||||||
|
return Optional.of(new ProviderResult(bestTitle, matchedManga.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<ProviderResult> searchMangaOnMyAnimeList(String title) {
|
||||||
|
var searchResults = myAnimeListService.searchMangaByTitle(title);
|
||||||
|
if (searchResults.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
var matchResponse =
|
||||||
|
titleMatcherService.findBestMatch(
|
||||||
|
TitleMatcherService.TitleMatchRequest.builder()
|
||||||
|
.title(title)
|
||||||
|
.options(searchResults.keySet())
|
||||||
|
.build());
|
||||||
|
if (!matchResponse.matchFound()) {
|
||||||
|
log.warn("No title match found for manga with title {} on MyAnimeList", title);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestTitle = matchResponse.bestMatch();
|
||||||
|
var malId = searchResults.get(bestTitle);
|
||||||
|
|
||||||
|
return Optional.of(new ProviderResult(bestTitle, malId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Manga findOrCreateManga(String canonicalTitle, Long aniListId, Long malId) {
|
||||||
|
if (nonNull(aniListId)) {
|
||||||
|
var existingByAniList = mangaRepository.findByAniListId(aniListId);
|
||||||
|
if (existingByAniList.isPresent()) {
|
||||||
|
return existingByAniList.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonNull(malId)) {
|
||||||
|
var existingByMalId = mangaRepository.findByMalId(malId);
|
||||||
|
if (existingByMalId.isPresent()) {
|
||||||
|
return existingByMalId.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mangaRepository
|
||||||
|
.findByTitleIgnoreCase(canonicalTitle)
|
||||||
|
.orElseGet(
|
||||||
|
() -> {
|
||||||
|
var newManga =
|
||||||
|
Manga.builder().title(canonicalTitle).malId(malId).aniListId(aniListId).build();
|
||||||
|
|
||||||
|
var savedManga = mangaRepository.save(newManga);
|
||||||
|
|
||||||
|
mangaUpdateProducer.sendMangaUpdateCommand(
|
||||||
|
new MangaUpdateCommand(savedManga.getId()));
|
||||||
|
|
||||||
|
return savedManga;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ProviderResult(String title, Long externalId) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.dto.MangaDTO;
|
||||||
|
import com.magamochi.catalog.model.dto.MangaListDTO;
|
||||||
|
import com.magamochi.catalog.model.dto.MangaListFilterDTO;
|
||||||
|
import com.magamochi.catalog.model.entity.Manga;
|
||||||
|
import com.magamochi.catalog.model.repository.MangaRepository;
|
||||||
|
import com.magamochi.common.exception.NotFoundException;
|
||||||
|
import com.magamochi.model.repository.UserFavoriteMangaRepository;
|
||||||
|
import com.magamochi.model.repository.UserMangaFollowRepository;
|
||||||
|
import com.magamochi.model.specification.MangaSpecification;
|
||||||
|
import com.magamochi.user.service.UserService;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaService {
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
private final MangaRepository mangaRepository;
|
||||||
|
private final UserFavoriteMangaRepository userFavoriteMangaRepository;
|
||||||
|
private final UserMangaFollowRepository userMangaFollowRepository;
|
||||||
|
|
||||||
|
public Manga find(long mangaId) {
|
||||||
|
return mangaRepository
|
||||||
|
.findById(mangaId)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Manga with ID " + mangaId + " not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<MangaListDTO> get(MangaListFilterDTO filterDTO, Pageable pageable) {
|
||||||
|
var user = userService.getLoggedUser();
|
||||||
|
|
||||||
|
var specification = MangaSpecification.getMangaListSpecification(filterDTO, user);
|
||||||
|
|
||||||
|
var favoriteMangasIds =
|
||||||
|
nonNull(user)
|
||||||
|
? userFavoriteMangaRepository.findByUser(user).stream()
|
||||||
|
.map(ufm -> ufm.getManga().getId())
|
||||||
|
.collect(Collectors.toSet())
|
||||||
|
: Set.of();
|
||||||
|
|
||||||
|
return mangaRepository
|
||||||
|
.findAll(specification, pageable)
|
||||||
|
.map(
|
||||||
|
manga -> {
|
||||||
|
var favorite = favoriteMangasIds.contains(manga.getId());
|
||||||
|
return MangaListDTO.from(manga, favorite);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public MangaDTO get(Long mangaId) {
|
||||||
|
var manga = find(mangaId);
|
||||||
|
var user = userService.getLoggedUser();
|
||||||
|
|
||||||
|
var favoriteMangasIds =
|
||||||
|
nonNull(user)
|
||||||
|
? userFavoriteMangaRepository.findByUser(user).stream()
|
||||||
|
.map(ufm -> ufm.getManga().getId())
|
||||||
|
.collect(Collectors.toSet())
|
||||||
|
: Set.of();
|
||||||
|
|
||||||
|
var followingMangaIds =
|
||||||
|
nonNull(user)
|
||||||
|
? userMangaFollowRepository.findByUser(user).stream()
|
||||||
|
.map(umf -> umf.getManga().getId())
|
||||||
|
.collect(Collectors.toSet())
|
||||||
|
: Set.of();
|
||||||
|
|
||||||
|
return MangaDTO.from(
|
||||||
|
manga,
|
||||||
|
favoriteMangasIds.contains(manga.getId()),
|
||||||
|
followingMangaIds.contains(manga.getId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,95 @@
|
|||||||
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.dto.MangaDataDTO;
|
||||||
|
import com.magamochi.catalog.model.entity.Manga;
|
||||||
|
import com.magamochi.catalog.model.enumeration.MangaState;
|
||||||
|
import com.magamochi.common.model.enumeration.ContentType;
|
||||||
|
import com.magamochi.common.queue.command.ImageFetchCommand;
|
||||||
|
import com.magamochi.common.queue.producer.ImageFetchProducer;
|
||||||
|
import com.magamochi.image.service.ImageService;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaUpdateService {
|
||||||
|
private final AniListService aniListService;
|
||||||
|
private final MyAnimeListService myAnimeListService;
|
||||||
|
private final MangaService mangaService;
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
|
private final ImageFetchProducer imageFetchProducer;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void update(long mangaId) {
|
||||||
|
log.info("Updating manga with ID {}", mangaId);
|
||||||
|
|
||||||
|
var manga = mangaService.find(mangaId);
|
||||||
|
var mangaData = fetchExternalMangaData(manga);
|
||||||
|
applyUpdatesToDatabase(manga, mangaData);
|
||||||
|
|
||||||
|
imageFetchProducer.sendImageFetchCommand(
|
||||||
|
new ImageFetchCommand(manga.getId(), ContentType.MANGA_COVER, mangaData.coverImageUrl()));
|
||||||
|
|
||||||
|
log.info("Manga with ID {} updated successfully", mangaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateMangaCoverImage(long mangaId, UUID imageId) {
|
||||||
|
log.info("Updating cover image for manga with ID {}", mangaId);
|
||||||
|
|
||||||
|
var manga = mangaService.find(mangaId);
|
||||||
|
var image = imageService.find(imageId);
|
||||||
|
|
||||||
|
manga.setCoverImage(image);
|
||||||
|
|
||||||
|
log.info("Manga with ID {} cover image updated successfully (Image ID {})", mangaId, imageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MangaDataDTO fetchExternalMangaData(Manga manga) {
|
||||||
|
if (nonNull(manga.getAniListId())) {
|
||||||
|
return aniListService.getMangaDataById(manga.getAniListId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonNull(manga.getMalId())) {
|
||||||
|
return myAnimeListService.getMangaDataById(manga.getMalId());
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Cannot update manga: No external provider IDs found for Manga " + manga.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyUpdatesToDatabase(Manga manga, MangaDataDTO mangaData) {
|
||||||
|
manga.setTitle(mangaData.title());
|
||||||
|
manga.setSynopsis(mangaData.synopsis());
|
||||||
|
manga.setScore(mangaData.score());
|
||||||
|
manga.setStatus(mangaData.status());
|
||||||
|
manga.setPublishedFrom(mangaData.publishedFrom());
|
||||||
|
manga.setPublishedTo(mangaData.publishedTo());
|
||||||
|
manga.setChapterCount(mangaData.chapterCount());
|
||||||
|
manga.setState(MangaState.AVAILABLE);
|
||||||
|
|
||||||
|
// TODO: properly save these
|
||||||
|
//
|
||||||
|
// mangaAlternativeTitleService.saveOrUpdateMangaAlternativeTitles(
|
||||||
|
// manga.getId(), mangaData.alternativeTitles());
|
||||||
|
//
|
||||||
|
// var genreIds =
|
||||||
|
// mangaData.genres().stream()
|
||||||
|
// .map(genreService::findOrCreateGenre)
|
||||||
|
// .collect(Collectors.toSet());
|
||||||
|
// mangaGenreService.saveOrUpdateMangaGenres(manga.getId(), genreIds);
|
||||||
|
//
|
||||||
|
// var authorIds =
|
||||||
|
// mangaData.authors().stream()
|
||||||
|
// .map(authorService::findOrCreateAuthor)
|
||||||
|
// .collect(Collectors.toSet());
|
||||||
|
// mangaAuthorService.saveOrUpdateMangaAuthors(manga.getId(), authorIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.isNull;
|
||||||
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
|
import com.google.common.util.concurrent.RateLimiter;
|
||||||
|
import com.magamochi.catalog.client.JikanClient;
|
||||||
|
import com.magamochi.catalog.model.dto.MangaDataDTO;
|
||||||
|
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
||||||
|
import com.magamochi.catalog.util.DoubleUtil;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MyAnimeListService {
|
||||||
|
private final JikanClient jikanClient;
|
||||||
|
private final RateLimiter jikanRateLimiter;
|
||||||
|
|
||||||
|
/// Searches for manga titles on MyAnimeList using the Jikan API and returns a map of title to MAL
|
||||||
|
// ID.
|
||||||
|
public Map<String, Long> searchMangaByTitle(String titleToSearch) {
|
||||||
|
jikanRateLimiter.acquire();
|
||||||
|
var results = jikanClient.mangaSearch(titleToSearch).data();
|
||||||
|
if (results.isEmpty()) {
|
||||||
|
log.warn("No manga found with title {}", titleToSearch);
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.stream()
|
||||||
|
.collect(
|
||||||
|
Collectors.toMap(
|
||||||
|
JikanClient.SearchResponse.MangaData::title,
|
||||||
|
JikanClient.SearchResponse.MangaData::mal_id,
|
||||||
|
(existing, second) -> existing));
|
||||||
|
}
|
||||||
|
|
||||||
|
public MangaDataDTO getMangaDataById(Long malId) {
|
||||||
|
jikanRateLimiter.acquire();
|
||||||
|
var response = jikanClient.getMangaById(malId);
|
||||||
|
if (isNull(response) || isNull(response.data())) {
|
||||||
|
log.warn("No manga found with MAL ID {}", malId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseData = response.data();
|
||||||
|
|
||||||
|
var authors =
|
||||||
|
responseData.authors().stream()
|
||||||
|
.map(JikanClient.MangaResponse.MangaData.AuthorData::name)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var genres =
|
||||||
|
responseData.genres().stream()
|
||||||
|
.map(JikanClient.MangaResponse.MangaData.GenreData::name)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var alternativeTitles = responseData.title_synonyms();
|
||||||
|
|
||||||
|
return MangaDataDTO.builder()
|
||||||
|
.title(responseData.title())
|
||||||
|
.score(nonNull(responseData.score()) ? DoubleUtil.round(responseData.score(), 2) : 0)
|
||||||
|
.synopsis(responseData.synopsis())
|
||||||
|
.chapterCount(responseData.chapters())
|
||||||
|
.publishedFrom(responseData.published().from())
|
||||||
|
.publishedTo(responseData.published().to())
|
||||||
|
.authors(authors)
|
||||||
|
.genres(genres)
|
||||||
|
.alternativeTitles(alternativeTitles)
|
||||||
|
.coverImageUrl(responseData.images().jpg().large_image_url())
|
||||||
|
.status(mapStatus(responseData.status()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MangaStatus mapStatus(String malStatus) {
|
||||||
|
return switch (malStatus) {
|
||||||
|
case "finished" -> MangaStatus.COMPLETED;
|
||||||
|
case "publishing" -> MangaStatus.ONGOING;
|
||||||
|
case "on hiatus" -> MangaStatus.HIATUS;
|
||||||
|
case "discontinued" -> MangaStatus.CANCELLED;
|
||||||
|
default -> MangaStatus.UNKNOWN;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,9 @@
|
|||||||
package com.magamochi.mangamochi.service;
|
package com.magamochi.catalog.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.isNull;
|
||||||
import static org.apache.commons.lang3.StringUtils.isBlank;
|
import static org.apache.commons.lang3.StringUtils.isBlank;
|
||||||
import static org.springframework.util.CollectionUtils.isEmpty;
|
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.dto.TitleMatchRequestDTO;
|
import lombok.Builder;
|
||||||
import com.magamochi.mangamochi.model.dto.TitleMatchResponseDTO;
|
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
import org.apache.commons.text.similarity.LevenshteinDistance;
|
import org.apache.commons.text.similarity.LevenshteinDistance;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -14,18 +13,24 @@ import org.springframework.stereotype.Service;
|
|||||||
public class TitleMatcherService {
|
public class TitleMatcherService {
|
||||||
private final LevenshteinDistance levenshteinDistance = LevenshteinDistance.getDefaultInstance();
|
private final LevenshteinDistance levenshteinDistance = LevenshteinDistance.getDefaultInstance();
|
||||||
|
|
||||||
public TitleMatchResponseDTO findBestMatch(TitleMatchRequestDTO request) {
|
public TitleMatchResponse findBestMatch(TitleMatchRequest request) {
|
||||||
if (isBlank(request.getTitle()) || isEmpty(request.getOptions())) {
|
if (isBlank(request.title()) || !request.options().iterator().hasNext()) {
|
||||||
throw new IllegalArgumentException("Title and options are required");
|
throw new IllegalArgumentException("Title and options are required");
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Finding best match for {}. Options: {}", request.getTitle(), request.getOptions());
|
// Set the default threshold if not specified
|
||||||
|
var threshold = request.threshold();
|
||||||
|
if (isNull(threshold) || threshold == 0) {
|
||||||
|
threshold = 85;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Finding best match for {}. Options: {}", request.title(), request.options());
|
||||||
|
|
||||||
String bestMatch = null;
|
String bestMatch = null;
|
||||||
double bestScore = 0.0;
|
double bestScore = 0.0;
|
||||||
|
|
||||||
for (var option : request.getOptions()) {
|
for (var option : request.options()) {
|
||||||
var score = calculateSimilarityScore(request.getTitle(), option);
|
var score = calculateSimilarityScore(request.title(), option);
|
||||||
|
|
||||||
if (score > bestScore) {
|
if (score > bestScore) {
|
||||||
bestScore = score;
|
bestScore = score;
|
||||||
@ -33,20 +38,20 @@ public class TitleMatcherService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestScore >= request.getThreshold()) {
|
if (bestScore >= threshold) {
|
||||||
log.info(
|
log.info(
|
||||||
"Found best match for {}: {}. Similarity: {}", request.getTitle(), bestMatch, bestScore);
|
"Found best match for {}: {}. Similarity: {}", request.title(), bestMatch, bestScore);
|
||||||
|
|
||||||
return TitleMatchResponseDTO.builder()
|
return TitleMatchResponse.builder()
|
||||||
.matchFound(true)
|
.matchFound(true)
|
||||||
.bestMatch(bestMatch)
|
.bestMatch(bestMatch)
|
||||||
.similarity(bestScore)
|
.similarity(bestScore)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("No match found for {}. Threshold: {}", request.getTitle(), request.getThreshold());
|
log.info("No match found for {}. Threshold: {}", request.title(), threshold);
|
||||||
|
|
||||||
return TitleMatchResponseDTO.builder().matchFound(false).build();
|
return TitleMatchResponse.builder().matchFound(false).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private double calculateSimilarityScore(String title, String option) {
|
private double calculateSimilarityScore(String title, String option) {
|
||||||
@ -64,4 +69,10 @@ public class TitleMatcherService {
|
|||||||
// Format to two decimal places for a cleaner result
|
// Format to two decimal places for a cleaner result
|
||||||
return Math.round(similarity * 100.0) / 100.0;
|
return Math.round(similarity * 100.0) / 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record TitleMatchRequest(String title, Iterable<String> options, Integer threshold) {}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public record TitleMatchResponse(boolean matchFound, String bestMatch, Double similarity) {}
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.util;
|
package com.magamochi.catalog.util;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
@ -1,14 +1,12 @@
|
|||||||
package com.magamochi.mangamochi.client;
|
package com.magamochi.client;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.dto.MangaDexMangaDTO;
|
import com.magamochi.model.dto.MangaDexMangaDTO;
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.cloud.openfeign.FeignClient;
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@FeignClient(name = "mangaDex", url = "https://api.mangadex.org")
|
@FeignClient(name = "mangaDex", url = "https://api.mangadex.org")
|
||||||
@Retry(name = "MangaDexRetry")
|
|
||||||
public interface MangaDexClient {
|
public interface MangaDexClient {
|
||||||
@GetMapping("/manga/{id}")
|
@GetMapping("/manga/{id}")
|
||||||
MangaDexMangaDTO getManga(@PathVariable UUID id);
|
MangaDexMangaDTO getManga(@PathVariable UUID id);
|
||||||
@ -1,12 +1,10 @@
|
|||||||
package com.magamochi.mangamochi.client;
|
package com.magamochi.client;
|
||||||
|
|
||||||
import io.github.resilience4j.retry.annotation.Retry;
|
|
||||||
import org.springframework.cloud.openfeign.FeignClient;
|
import org.springframework.cloud.openfeign.FeignClient;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
@FeignClient(name = "ntfy", url = "${ntfy.endpoint}")
|
@FeignClient(name = "ntfy", url = "${ntfy.endpoint}")
|
||||||
@Retry(name = "JikanRetry")
|
|
||||||
public interface NtfyClient {
|
public interface NtfyClient {
|
||||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
void notify(@RequestBody Request dto);
|
void notify(@RequestBody Request dto);
|
||||||
140
src/main/java/com/magamochi/common/config/RabbitConfig.java
Normal file
140
src/main/java/com/magamochi/common/config/RabbitConfig.java
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
package com.magamochi.common.config;
|
||||||
|
|
||||||
|
import com.magamochi.common.model.enumeration.ContentType;
|
||||||
|
import org.springframework.amqp.core.Binding;
|
||||||
|
import org.springframework.amqp.core.Queue;
|
||||||
|
import org.springframework.amqp.core.TopicExchange;
|
||||||
|
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RabbitConfig {
|
||||||
|
@Value("${queues.manga-ingest}")
|
||||||
|
private String mangaIngestQueue;
|
||||||
|
|
||||||
|
@Value("${queues.manga-content-ingest}")
|
||||||
|
private String mangaContentIngestQueue;
|
||||||
|
|
||||||
|
@Value("${queues.manga-content-image-ingest}")
|
||||||
|
private String mangaContentImageIngestQueue;
|
||||||
|
|
||||||
|
@Value("${queues.provider-page-ingest}")
|
||||||
|
private String providerPageIngestQueue;
|
||||||
|
|
||||||
|
@Value("${queues.manga-update}")
|
||||||
|
private String mangaUpdateQueue;
|
||||||
|
|
||||||
|
@Value("${queues.manga-cover-update}")
|
||||||
|
private String mangaCoverUpdateQueue;
|
||||||
|
|
||||||
|
@Value("${queues.manga-content-image-update}")
|
||||||
|
private String mangaContentImageUpdateQueue;
|
||||||
|
|
||||||
|
@Value("${queues.image-fetch}")
|
||||||
|
private String imageFetchQueue;
|
||||||
|
|
||||||
|
@Value("${topics.image-updates}")
|
||||||
|
private String imageUpdatesTopic;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TopicExchange imageUpdatesExchange() {
|
||||||
|
return new TopicExchange(imageUpdatesTopic);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue imageFetchQueue() {
|
||||||
|
return new Queue(imageFetchQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue mangaUpdateQueue() {
|
||||||
|
return new Queue(mangaUpdateQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue mangaContentImageUpdateQueue() {
|
||||||
|
return new Queue(mangaContentImageUpdateQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue mangaCoverUpdateQueue() {
|
||||||
|
return new Queue(mangaCoverUpdateQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Binding bindingMangaCoverUpdateQueue(
|
||||||
|
Queue mangaCoverUpdateQueue, TopicExchange imageUpdatesExchange) {
|
||||||
|
return new Binding(
|
||||||
|
mangaCoverUpdateQueue.getName(),
|
||||||
|
Binding.DestinationType.QUEUE,
|
||||||
|
imageUpdatesExchange.getName(),
|
||||||
|
String.format("image.update.%s", ContentType.MANGA_COVER.name().toLowerCase()),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Binding bindingMangaContentImageUpdateQueue(
|
||||||
|
Queue mangaContentImageUpdateQueue, TopicExchange imageUpdatesExchange) {
|
||||||
|
return new Binding(
|
||||||
|
mangaContentImageUpdateQueue.getName(),
|
||||||
|
Binding.DestinationType.QUEUE,
|
||||||
|
imageUpdatesExchange.getName(),
|
||||||
|
String.format("image.update.%s", ContentType.CONTENT_IMAGE.name().toLowerCase()),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue mangaContentIngestQueue() {
|
||||||
|
return new Queue(mangaContentIngestQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue mangaContentImageIngestQueue() {
|
||||||
|
return new Queue(mangaContentImageIngestQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue mangaIngestQueue() {
|
||||||
|
return new Queue(mangaIngestQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue providerPageIngestQueue() {
|
||||||
|
return new Queue(providerPageIngestQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove unused queues
|
||||||
|
|
||||||
|
@Value("${rabbit-mq.queues.manga-chapter-download}")
|
||||||
|
private String mangaChapterDownloadQueue;
|
||||||
|
|
||||||
|
@Value("${rabbit-mq.queues.manga-follow-update-chapter}")
|
||||||
|
private String mangaFollowUpdateChapterQueue;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue mangaChapterDownloadQueue() {
|
||||||
|
return new Queue(mangaChapterDownloadQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue mangaFollowUpdateChapterQueue() {
|
||||||
|
return new Queue(mangaFollowUpdateChapterQueue, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Jackson2JsonMessageConverter messageConverter() {
|
||||||
|
return new Jackson2JsonMessageConverter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
|
||||||
|
var rabbitTemplate = new RabbitTemplate(connectionFactory);
|
||||||
|
rabbitTemplate.setMessageConverter(messageConverter());
|
||||||
|
|
||||||
|
return rabbitTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.config;
|
package com.magamochi.common.config;
|
||||||
|
|
||||||
import com.google.common.util.concurrent.RateLimiter;
|
import com.google.common.util.concurrent.RateLimiter;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@ -16,6 +16,11 @@ public class RateLimiterConfig {
|
|||||||
return RateLimiter.create(1);
|
return RateLimiter.create(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RateLimiter aniListRateLimiter() {
|
||||||
|
return RateLimiter.create(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public RateLimiter imageDownloadRateLimiter() {
|
public RateLimiter imageDownloadRateLimiter() {
|
||||||
return RateLimiter.create(10);
|
return RateLimiter.create(10);
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.config;
|
package com.magamochi.common.config;
|
||||||
|
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.exception;
|
package com.magamochi.common.exception;
|
||||||
|
|
||||||
public class ConflictException extends RuntimeException {
|
public class ConflictException extends RuntimeException {
|
||||||
public ConflictException(String message) {
|
public ConflictException(String message) {
|
||||||
@ -1,6 +1,6 @@
|
|||||||
package com.magamochi.mangamochi.exception;
|
package com.magamochi.common.exception;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.dto.ErrorResponseDTO;
|
import com.magamochi.common.model.dto.ErrorResponseDTO;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.exception;
|
package com.magamochi.common.exception;
|
||||||
|
|
||||||
public class NotFoundException extends RuntimeException {
|
public class NotFoundException extends RuntimeException {
|
||||||
public NotFoundException(String message) {
|
public NotFoundException(String message) {
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.exception;
|
package com.magamochi.common.exception;
|
||||||
|
|
||||||
public class UnprocessableException extends RuntimeException {
|
public class UnprocessableException extends RuntimeException {
|
||||||
public UnprocessableException(String message) {
|
public UnprocessableException(String message) {
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.common.model.dto;
|
||||||
|
|
||||||
import jakarta.annotation.Nullable;
|
import jakarta.annotation.Nullable;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.common.model.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.magamochi.common.model.enumeration;
|
||||||
|
|
||||||
|
public enum ContentType {
|
||||||
|
MANGA_COVER,
|
||||||
|
CHAPTER,
|
||||||
|
VOLUME,
|
||||||
|
CONTENT_IMAGE,
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.magamochi.common.queue.command;
|
||||||
|
|
||||||
|
import com.magamochi.common.model.enumeration.ContentType;
|
||||||
|
|
||||||
|
public record ImageFetchCommand(long entityId, ContentType contentType, String url) {}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.magamochi.common.queue.command;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record ImageUpdateCommand(long entityId, UUID imageId) {}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.magamochi.common.queue.command;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record MangaContentImageIngestCommand(
|
||||||
|
long mangaContentId, @NotBlank String url, int position, boolean isLast) {}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.magamochi.common.queue.command;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record MangaContentIngestCommand(
|
||||||
|
long mangaContentProviderId,
|
||||||
|
@NotBlank String title,
|
||||||
|
@NotBlank String url,
|
||||||
|
@NotBlank String languageCode) {}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.magamochi.common.queue.command;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
public record MangaIngestCommand(
|
||||||
|
long providerId, @NotBlank String mangaTitle, @NotBlank String url) {}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.magamochi.common.queue.producer;
|
||||||
|
|
||||||
|
import com.magamochi.common.queue.command.ImageFetchCommand;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ImageFetchProducer {
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
|
||||||
|
@Value("${queues.image-fetch}")
|
||||||
|
private String imageFetchQueue;
|
||||||
|
|
||||||
|
public void sendImageFetchCommand(ImageFetchCommand command) {
|
||||||
|
rabbitTemplate.convertAndSend(imageFetchQueue, command);
|
||||||
|
log.info("Sent image fetch command: {}", command);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package com.magamochi.content.controller;
|
||||||
|
|
||||||
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
|
import com.magamochi.content.model.dto.MangaContentDTO;
|
||||||
|
import com.magamochi.content.service.ContentService;
|
||||||
|
import com.magamochi.model.dto.MangaContentImagesDTO;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/content")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContentController {
|
||||||
|
private final ContentService contentService;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get the content for a specific manga/content provider combination",
|
||||||
|
description = "Retrieve the content for a specific manga/content provider combination.",
|
||||||
|
tags = {"Content"},
|
||||||
|
operationId = "getMangaProviderContent")
|
||||||
|
@GetMapping("/{mangaContentProviderId}")
|
||||||
|
public DefaultResponseDTO<List<MangaContentDTO>> getMangaProviderContent(
|
||||||
|
@PathVariable @NotNull Long mangaContentProviderId) {
|
||||||
|
return DefaultResponseDTO.ok(contentService.getContent(mangaContentProviderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Get the content images for a specific manga/provider combination",
|
||||||
|
description =
|
||||||
|
"Retrieve a list of manga content images for a specific manga/provider combination.",
|
||||||
|
tags = {"Content"},
|
||||||
|
operationId = "getMangaContentImages")
|
||||||
|
@GetMapping("/{mangaContentId}/images")
|
||||||
|
public DefaultResponseDTO<MangaContentImagesDTO> getMangaContentImages(
|
||||||
|
@PathVariable Long mangaContentId) {
|
||||||
|
return DefaultResponseDTO.ok(contentService.getContentImages(mangaContentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.magamochi.mangamochi.model.dto;
|
package com.magamochi.content.model.dto;
|
||||||
|
|
||||||
import static java.util.Objects.isNull;
|
import static java.util.Objects.isNull;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.entity.Language;
|
import com.magamochi.catalog.model.entity.Language;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
package com.magamochi.content.model.dto;
|
||||||
|
|
||||||
|
import com.magamochi.content.model.entity.MangaContent;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record MangaContentDTO(
|
||||||
|
@NotNull Long id,
|
||||||
|
@NotBlank String title,
|
||||||
|
@NotNull Boolean downloaded,
|
||||||
|
@NotNull Boolean isRead,
|
||||||
|
LanguageDTO language) {
|
||||||
|
public static MangaContentDTO from(MangaContent mangaContent) {
|
||||||
|
return new MangaContentDTO(
|
||||||
|
mangaContent.getId(),
|
||||||
|
mangaContent.getTitle(),
|
||||||
|
mangaContent.getDownloaded(),
|
||||||
|
false,
|
||||||
|
LanguageDTO.from(mangaContent.getLanguage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.content.model.entity;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.model.entity.Language;
|
||||||
|
import com.magamochi.catalog.model.entity.MangaContentProvider;
|
||||||
|
import com.magamochi.common.model.enumeration.ContentType;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -8,20 +11,22 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "manga_chapters")
|
@Table(name = "manga_contents")
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
public class MangaChapter {
|
public class MangaContent {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "manga_provider_id")
|
@JoinColumn(name = "manga_content_provider_id")
|
||||||
private MangaProvider mangaProvider;
|
private MangaContentProvider mangaContentProvider;
|
||||||
|
|
||||||
|
@Builder.Default private ContentType type = ContentType.CHAPTER;
|
||||||
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@ -29,16 +34,12 @@ public class MangaChapter {
|
|||||||
|
|
||||||
@Builder.Default private Boolean downloaded = false;
|
@Builder.Default private Boolean downloaded = false;
|
||||||
|
|
||||||
@Builder.Default private Boolean read = false;
|
|
||||||
|
|
||||||
@CreationTimestamp private Instant createdAt;
|
@CreationTimestamp private Instant createdAt;
|
||||||
|
|
||||||
@UpdateTimestamp private Instant updatedAt;
|
@UpdateTimestamp private Instant updatedAt;
|
||||||
|
|
||||||
@OneToMany(mappedBy = "mangaChapter")
|
@OneToMany(mappedBy = "mangaContent")
|
||||||
private List<MangaChapterImage> mangaChapterImages;
|
private List<MangaContentImage> mangaContentImages;
|
||||||
|
|
||||||
private Integer chapterNumber;
|
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "language_id")
|
@JoinColumn(name = "language_id")
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.content.model.entity;
|
||||||
|
|
||||||
|
import com.magamochi.image.model.entity.Image;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
@ -7,20 +8,20 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "manga_chapter_images")
|
@Table(name = "manga_content_images")
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
public class MangaChapterImage {
|
public class MangaContentImage {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@ManyToOne
|
@ManyToOne
|
||||||
@JoinColumn(name = "manga_chapter_id")
|
@JoinColumn(name = "manga_content_id")
|
||||||
private MangaChapter mangaChapter;
|
private MangaContent mangaContent;
|
||||||
|
|
||||||
@OneToOne
|
@OneToOne
|
||||||
@JoinColumn(name = "image_id")
|
@JoinColumn(name = "image_id")
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.magamochi.content.model.repository;
|
||||||
|
|
||||||
|
import com.magamochi.content.model.entity.MangaContent;
|
||||||
|
import com.magamochi.content.model.entity.MangaContentImage;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface MangaContentImageRepository extends JpaRepository<MangaContentImage, Long> {
|
||||||
|
List<MangaContentImage> findAllByMangaContent(MangaContent mangaContent);
|
||||||
|
|
||||||
|
boolean existsByMangaContent_IdAndPosition(Long mangaContentId, int position);
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.magamochi.content.model.repository;
|
||||||
|
|
||||||
|
import com.magamochi.content.model.entity.MangaContent;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface MangaContentRepository extends JpaRepository<MangaContent, Long> {
|
||||||
|
boolean existsByMangaContentProvider_IdAndUrlIgnoreCase(Long mangaContentProviderId, String url);
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.magamochi.content.queue.consumer;
|
||||||
|
|
||||||
|
import com.magamochi.common.queue.command.MangaContentImageIngestCommand;
|
||||||
|
import com.magamochi.content.service.ContentIngestService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaContentImageIngestConsumer {
|
||||||
|
private final ContentIngestService contentIngestService;
|
||||||
|
|
||||||
|
@RabbitListener(queues = "${queues.manga-content-image-ingest}")
|
||||||
|
public void receiveMangaContentImageIngestCommand(MangaContentImageIngestCommand command) {
|
||||||
|
log.info("Received manga content ingest command: {}", command);
|
||||||
|
contentIngestService.ingestImages(
|
||||||
|
command.mangaContentId(), command.url(), command.position(), command.isLast());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.magamochi.content.queue.consumer;
|
||||||
|
|
||||||
|
import com.magamochi.common.queue.command.ImageUpdateCommand;
|
||||||
|
import com.magamochi.content.service.ContentIngestService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaContentImageUpdateConsumer {
|
||||||
|
private final ContentIngestService contentIngestService;
|
||||||
|
|
||||||
|
@RabbitListener(queues = "${queues.manga-content-image-update}")
|
||||||
|
public void receiveMangaContentImageUpdateCommand(ImageUpdateCommand command) {
|
||||||
|
log.info("Received manga content image update command: {}", command);
|
||||||
|
|
||||||
|
contentIngestService.updateMangaContentImage(command.entityId(), command.imageId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package com.magamochi.content.queue.consumer;
|
||||||
|
|
||||||
|
import com.magamochi.common.queue.command.MangaContentIngestCommand;
|
||||||
|
import com.magamochi.content.service.ContentIngestService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaContentIngestConsumer {
|
||||||
|
private final ContentIngestService contentIngestService;
|
||||||
|
|
||||||
|
@RabbitListener(queues = "${queues.manga-content-ingest}")
|
||||||
|
public void receiveMangaContentIngestCommand(MangaContentIngestCommand command) {
|
||||||
|
log.info("Received manga content ingest command: {}", command);
|
||||||
|
contentIngestService.ingest(
|
||||||
|
command.mangaContentProviderId(), command.title(), command.url(), command.languageCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
package com.magamochi.content.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.isNull;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.service.LanguageService;
|
||||||
|
import com.magamochi.catalog.service.MangaContentProviderService;
|
||||||
|
import com.magamochi.common.exception.NotFoundException;
|
||||||
|
import com.magamochi.common.model.enumeration.ContentType;
|
||||||
|
import com.magamochi.common.queue.command.ImageFetchCommand;
|
||||||
|
import com.magamochi.common.queue.producer.ImageFetchProducer;
|
||||||
|
import com.magamochi.content.model.entity.MangaContent;
|
||||||
|
import com.magamochi.content.model.entity.MangaContentImage;
|
||||||
|
import com.magamochi.content.model.repository.MangaContentImageRepository;
|
||||||
|
import com.magamochi.content.model.repository.MangaContentRepository;
|
||||||
|
import com.magamochi.image.service.ImageService;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContentIngestService {
|
||||||
|
private final ContentService contentService;
|
||||||
|
private final MangaContentProviderService mangaContentProviderService;
|
||||||
|
private final LanguageService languageService;
|
||||||
|
|
||||||
|
private final MangaContentRepository mangaContentRepository;
|
||||||
|
private final MangaContentImageRepository mangaContentImageRepository;
|
||||||
|
|
||||||
|
private final ImageFetchProducer imageFetchProducer;
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
|
public void ingest(
|
||||||
|
long mangaContentProviderId,
|
||||||
|
@NotBlank String title,
|
||||||
|
@NotBlank String url,
|
||||||
|
@NotBlank String languageCode) {
|
||||||
|
log.info("Ingesting Manga Content ({}) for provider {}", title, mangaContentProviderId);
|
||||||
|
|
||||||
|
var mangaContentProvider = mangaContentProviderService.find(mangaContentProviderId);
|
||||||
|
|
||||||
|
if (mangaContentRepository.existsByMangaContentProvider_IdAndUrlIgnoreCase(
|
||||||
|
mangaContentProvider.getId(), url)) {
|
||||||
|
log.info(
|
||||||
|
"Manga Content ({}) for provider {} already exists. Skipped.",
|
||||||
|
title,
|
||||||
|
mangaContentProviderId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var language = languageService.find(languageCode);
|
||||||
|
|
||||||
|
var mangaContent =
|
||||||
|
mangaContentRepository.save(
|
||||||
|
MangaContent.builder()
|
||||||
|
.mangaContentProvider(mangaContentProvider)
|
||||||
|
.title(title)
|
||||||
|
.url(url)
|
||||||
|
.language(language)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Ingested Manga Content ({}) for provider {}: {}",
|
||||||
|
title,
|
||||||
|
mangaContentProviderId,
|
||||||
|
mangaContent.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void ingestImages(
|
||||||
|
long mangaContentId, @NotBlank String url, int position, boolean isLast) {
|
||||||
|
log.info(
|
||||||
|
"Ingesting Manga Content Image for MangaContent {}, position {}", mangaContentId, position);
|
||||||
|
|
||||||
|
var mangaContent = contentService.find(mangaContentId);
|
||||||
|
|
||||||
|
if (mangaContentImageRepository.existsByMangaContent_IdAndPosition(mangaContentId, position)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mangaContentImage =
|
||||||
|
mangaContentImageRepository.save(
|
||||||
|
MangaContentImage.builder().mangaContent(mangaContent).position(position).build());
|
||||||
|
|
||||||
|
imageFetchProducer.sendImageFetchCommand(
|
||||||
|
new ImageFetchCommand(mangaContentImage.getId(), ContentType.CONTENT_IMAGE, url));
|
||||||
|
|
||||||
|
if (isLast) {
|
||||||
|
mangaContent.setDownloaded(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateMangaContentImage(long mangaContentImageId, UUID imageId) {
|
||||||
|
if (isNull(imageId)) {
|
||||||
|
log.error("Null imageID received!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mangaContentImage =
|
||||||
|
mangaContentImageRepository
|
||||||
|
.findById(mangaContentImageId)
|
||||||
|
.orElseThrow(
|
||||||
|
() -> new NotFoundException("Image not found for ID: " + mangaContentImageId));
|
||||||
|
|
||||||
|
var image = imageService.find(imageId);
|
||||||
|
mangaContentImage.setImage(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package com.magamochi.content.service;
|
||||||
|
|
||||||
|
import com.magamochi.catalog.service.MangaContentProviderService;
|
||||||
|
import com.magamochi.common.exception.NotFoundException;
|
||||||
|
import com.magamochi.content.model.dto.MangaContentDTO;
|
||||||
|
import com.magamochi.content.model.entity.MangaContent;
|
||||||
|
import com.magamochi.content.model.repository.MangaContentRepository;
|
||||||
|
import com.magamochi.model.dto.MangaContentImagesDTO;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContentService {
|
||||||
|
private final MangaContentProviderService mangaContentProviderService;
|
||||||
|
|
||||||
|
private final MangaContentRepository mangaContentRepository;
|
||||||
|
|
||||||
|
public List<MangaContentDTO> getContent(@NotNull Long mangaContentProviderId) {
|
||||||
|
var mangaContentProvider = mangaContentProviderService.find(mangaContentProviderId);
|
||||||
|
|
||||||
|
return mangaContentProvider.getMangaContents().stream()
|
||||||
|
.sorted(Comparator.comparing(MangaContent::getId))
|
||||||
|
.map(MangaContentDTO::from)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MangaContent find(Long id) {
|
||||||
|
return mangaContentRepository
|
||||||
|
.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("MangaContent not found for ID: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public MangaContentImagesDTO getContentImages(Long mangaContentId) {
|
||||||
|
var mangaContent = find(mangaContentId);
|
||||||
|
|
||||||
|
var chapters =
|
||||||
|
mangaContent.getMangaContentProvider().getMangaContents().stream()
|
||||||
|
.sorted(Comparator.comparing(MangaContent::getId))
|
||||||
|
.toList();
|
||||||
|
Long prevId = null;
|
||||||
|
Long nextId = null;
|
||||||
|
|
||||||
|
// TODO: this doesn't perform well for large datasets
|
||||||
|
for (var i = 0; i < chapters.size(); i++) {
|
||||||
|
if (chapters.get(i).getId().equals(mangaContent.getId())) {
|
||||||
|
if (i > 0) {
|
||||||
|
prevId = chapters.get(i - 1).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < chapters.size() - 1) {
|
||||||
|
nextId = chapters.get(i + 1).getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangaContentImagesDTO.from(mangaContent, prevId, nextId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
package com.magamochi.controller;
|
||||||
|
|
||||||
|
import com.magamochi.client.NtfyClient;
|
||||||
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
|
import com.magamochi.image.task.ImageCleanupTask;
|
||||||
|
import com.magamochi.ingestion.task.IngestFromContentProvidersTask;
|
||||||
|
import com.magamochi.task.MangaFollowUpdateTask;
|
||||||
|
import com.magamochi.user.repository.UserRepository;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/management")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ManagementController {
|
||||||
|
private final IngestFromContentProvidersTask ingestFromContentProvidersTask;
|
||||||
|
private final ImageCleanupTask imageCleanupTask;
|
||||||
|
private final MangaFollowUpdateTask mangaFollowUpdateTask;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final NtfyClient ntfyClient;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Cleanup unused S3 images",
|
||||||
|
description = "Triggers the cleanup of untracked S3 images",
|
||||||
|
tags = {"Management"},
|
||||||
|
operationId = "imageCleanup")
|
||||||
|
@PostMapping("image-cleanup")
|
||||||
|
public DefaultResponseDTO<Void> imageCleanup() {
|
||||||
|
imageCleanupTask.cleanupImages();
|
||||||
|
|
||||||
|
return DefaultResponseDTO.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Trigger user follow update",
|
||||||
|
description = "Trigger user follow update",
|
||||||
|
tags = {"Management"},
|
||||||
|
operationId = "userFollowUpdate")
|
||||||
|
@PostMapping("user-follow")
|
||||||
|
public DefaultResponseDTO<Void> triggerUserFollowUpdate() {
|
||||||
|
mangaFollowUpdateTask.updateMangaList();
|
||||||
|
|
||||||
|
return DefaultResponseDTO.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Test notification",
|
||||||
|
description = "Sends a test notification to all users",
|
||||||
|
tags = {"Management"},
|
||||||
|
operationId = "testNotification")
|
||||||
|
@PostMapping("test-notification")
|
||||||
|
public DefaultResponseDTO<Void> testNotification() {
|
||||||
|
var users = userRepository.findAll();
|
||||||
|
|
||||||
|
users.forEach(
|
||||||
|
user ->
|
||||||
|
ntfyClient.notify(
|
||||||
|
new NtfyClient.Request(
|
||||||
|
"mangamochi-" + user.getId().toString(),
|
||||||
|
"Mangamochi",
|
||||||
|
"This is a test notification :)")));
|
||||||
|
|
||||||
|
return DefaultResponseDTO.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
package com.magamochi.mangamochi.controller;
|
package com.magamochi.controller;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.dto.*;
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
import com.magamochi.mangamochi.model.enumeration.ArchiveFileType;
|
import com.magamochi.model.enumeration.ArchiveFileType;
|
||||||
import com.magamochi.mangamochi.service.MangaChapterService;
|
import com.magamochi.service.MangaChapterService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.media.Content;
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
@ -20,30 +20,6 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
public class MangaChapterController {
|
public class MangaChapterController {
|
||||||
private final MangaChapterService mangaChapterService;
|
private final MangaChapterService mangaChapterService;
|
||||||
|
|
||||||
@Operation(
|
|
||||||
summary = "Fetch chapter",
|
|
||||||
description = "Fetch the chapter from the provider",
|
|
||||||
tags = {"Manga Chapter"},
|
|
||||||
operationId = "fetchChapter")
|
|
||||||
@PostMapping(value = "/{chapterId}/fetch")
|
|
||||||
public DefaultResponseDTO<Void> fetchChapter(@PathVariable Long chapterId) {
|
|
||||||
mangaChapterService.fetchChapter(chapterId);
|
|
||||||
|
|
||||||
return DefaultResponseDTO.ok().build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(
|
|
||||||
summary = "Get the images for a specific manga/provider combination",
|
|
||||||
description =
|
|
||||||
"Retrieve a list of manga chapter images for a specific manga/provider combination.",
|
|
||||||
tags = {"Manga Chapter"},
|
|
||||||
operationId = "getMangaChapterImages")
|
|
||||||
@GetMapping("/{chapterId}/images")
|
|
||||||
public DefaultResponseDTO<MangaChapterImagesDTO> getMangaChapterImages(
|
|
||||||
@PathVariable Long chapterId) {
|
|
||||||
return DefaultResponseDTO.ok(mangaChapterService.getMangaChapterImages(chapterId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Mark a chapter as read",
|
summary = "Mark a chapter as read",
|
||||||
description = "Mark a chapter as read by its ID.",
|
description = "Mark a chapter as read by its ID.",
|
||||||
50
src/main/java/com/magamochi/controller/MangaController.java
Normal file
50
src/main/java/com/magamochi/controller/MangaController.java
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package com.magamochi.controller;
|
||||||
|
|
||||||
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
|
import com.magamochi.service.OldMangaService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/mangas")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MangaController {
|
||||||
|
private final OldMangaService oldMangaService;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Fetch all chapters",
|
||||||
|
description = "Fetch all not yet downloaded chapters from the provider",
|
||||||
|
tags = {"Manga Chapter"},
|
||||||
|
operationId = "fetchAllChapters")
|
||||||
|
@PostMapping(value = "/{mangaProviderId}/fetch-all-chapters")
|
||||||
|
public DefaultResponseDTO<Void> fetchAllChapters(@PathVariable Long mangaProviderId) {
|
||||||
|
oldMangaService.fetchAllNotDownloadedChapters(mangaProviderId);
|
||||||
|
|
||||||
|
return DefaultResponseDTO.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Follow the manga specified by its ID",
|
||||||
|
description = "Follow the manga specified by its ID.",
|
||||||
|
tags = {"Manga"},
|
||||||
|
operationId = "followManga")
|
||||||
|
@PostMapping("/{mangaId}/followManga")
|
||||||
|
public DefaultResponseDTO<Void> followManga(@PathVariable Long mangaId) {
|
||||||
|
oldMangaService.follow(mangaId);
|
||||||
|
|
||||||
|
return DefaultResponseDTO.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "Unfollow the manga specified by its ID",
|
||||||
|
description = "Unfollow the manga specified by its ID.",
|
||||||
|
tags = {"Manga"},
|
||||||
|
operationId = "unfollowManga")
|
||||||
|
@PostMapping("/{mangaId}/unfollowManga")
|
||||||
|
public DefaultResponseDTO<Void> unfollowManga(@PathVariable Long mangaId) {
|
||||||
|
oldMangaService.unfollow(mangaId);
|
||||||
|
|
||||||
|
return DefaultResponseDTO.ok().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
package com.magamochi.mangamochi.controller;
|
package com.magamochi.controller;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.dto.*;
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
import com.magamochi.mangamochi.service.MangaImportService;
|
import com.magamochi.model.dto.ImportMangaResponseDTO;
|
||||||
import com.magamochi.mangamochi.service.ProviderManualMangaImportService;
|
import com.magamochi.model.dto.ImportRequestDTO;
|
||||||
|
// import com.magamochi.service.MangaImportService;
|
||||||
|
import com.magamochi.service.ProviderManualMangaImportService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.media.Content;
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
@ -18,7 +20,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
@RequestMapping("/manga/import")
|
@RequestMapping("/manga/import")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MangaImportController {
|
public class MangaImportController {
|
||||||
private final MangaImportService mangaImportService;
|
// private final MangaImportService mangaImportService;
|
||||||
private final ProviderManualMangaImportService providerManualMangaImportService;
|
private final ProviderManualMangaImportService providerManualMangaImportService;
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
@ -53,7 +55,7 @@ public class MangaImportController {
|
|||||||
@RequestPart("files")
|
@RequestPart("files")
|
||||||
@NotNull
|
@NotNull
|
||||||
List<MultipartFile> files) {
|
List<MultipartFile> files) {
|
||||||
mangaImportService.importMangaFiles(malId, files);
|
// mangaImportService.importMangaFiles(malId, files);
|
||||||
|
|
||||||
return DefaultResponseDTO.ok().build();
|
return DefaultResponseDTO.ok().build();
|
||||||
}
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
package com.magamochi.mangamochi.controller;
|
package com.magamochi.controller;
|
||||||
|
|
||||||
import com.magamochi.mangamochi.model.dto.DefaultResponseDTO;
|
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||||
import com.magamochi.mangamochi.service.UserFavoriteMangaService;
|
import com.magamochi.service.UserFavoriteMangaService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.config;
|
package com.magamochi.image.config;
|
||||||
|
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.magamochi.mangamochi.model.entity;
|
package com.magamochi.image.model.entity;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@ -19,7 +19,9 @@ public class Image {
|
|||||||
@GeneratedValue(strategy = GenerationType.UUID)
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
private UUID id;
|
private UUID id;
|
||||||
|
|
||||||
private String fileKey;
|
private String objectKey;
|
||||||
|
|
||||||
|
private String fileHash;
|
||||||
|
|
||||||
@CreationTimestamp private Instant createdAt;
|
@CreationTimestamp private Instant createdAt;
|
||||||
|
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.magamochi.image.model.repository;
|
||||||
|
|
||||||
|
import com.magamochi.image.model.entity.Image;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface ImageRepository extends JpaRepository<Image, UUID> {
|
||||||
|
Optional<Image> findByFileHash(String fileHash);
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.magamochi.image.queue.consumer;
|
||||||
|
|
||||||
|
import com.magamochi.common.queue.command.ImageFetchCommand;
|
||||||
|
import com.magamochi.common.queue.command.ImageUpdateCommand;
|
||||||
|
import com.magamochi.image.queue.producer.ImageUpdateProducer;
|
||||||
|
import com.magamochi.image.service.ImageFetchService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ImageFetchConsumer {
|
||||||
|
private final ImageFetchService imageFetchService;
|
||||||
|
private final ImageUpdateProducer imageUpdateProducer;
|
||||||
|
|
||||||
|
@RabbitListener(queues = "${queues.image-fetch}")
|
||||||
|
public void receiveImageFetchCommand(ImageFetchCommand command) {
|
||||||
|
log.info("Received image fetch command: {}", command);
|
||||||
|
|
||||||
|
var imageId = imageFetchService.fetchImage(command.url(), command.contentType());
|
||||||
|
|
||||||
|
imageUpdateProducer.publishImageUpdateCommand(
|
||||||
|
new ImageUpdateCommand(command.entityId(), imageId), command.contentType());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.magamochi.image.queue.producer;
|
||||||
|
|
||||||
|
import com.magamochi.common.model.enumeration.ContentType;
|
||||||
|
import com.magamochi.common.queue.command.ImageUpdateCommand;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ImageUpdateProducer {
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
|
||||||
|
@Value("${topics.image-updates}")
|
||||||
|
private String imageUpdatesTopic;
|
||||||
|
|
||||||
|
public void publishImageUpdateCommand(ImageUpdateCommand command, ContentType contentType) {
|
||||||
|
var routingKey = String.format("image.update.%s", contentType.name().toLowerCase());
|
||||||
|
|
||||||
|
rabbitTemplate.convertAndSend(imageUpdatesTopic, routingKey, command);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
package com.magamochi.image.service;
|
||||||
|
|
||||||
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
|
import com.google.common.util.concurrent.RateLimiter;
|
||||||
|
import com.magamochi.common.model.enumeration.ContentType;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.apache.tika.Tika;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ImageFetchService {
|
||||||
|
private final ImageService imageManagerService;
|
||||||
|
|
||||||
|
private final RateLimiter imageDownloadRateLimiter;
|
||||||
|
|
||||||
|
private final HttpClient httpClient =
|
||||||
|
HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
|
||||||
|
private final Tika tika = new Tika();
|
||||||
|
|
||||||
|
public UUID fetchImage(String imageUrl, ContentType contentType) {
|
||||||
|
try {
|
||||||
|
var request = HttpRequest.newBuilder(URI.create(imageUrl.trim())).GET().build();
|
||||||
|
|
||||||
|
imageDownloadRateLimiter.acquire();
|
||||||
|
var response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
|
||||||
|
var imageBytes = response.body();
|
||||||
|
|
||||||
|
var fileContentType = resolveContentType(response, imageBytes);
|
||||||
|
|
||||||
|
var fileHash = computeHash(imageBytes);
|
||||||
|
|
||||||
|
return imageManagerService.upload(
|
||||||
|
imageBytes, fileContentType, contentType.name().toLowerCase(), fileHash);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to fetch image from URL: {}", imageUrl, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveContentType(HttpResponse<byte[]> response, byte[] fileBytes) {
|
||||||
|
var headerType =
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.firstValue("Content-Type")
|
||||||
|
.map(val -> val.split(";")[0].trim().toLowerCase())
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (nonNull(headerType) && headerType.startsWith("image/")) {
|
||||||
|
return headerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tika.detect(fileBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String computeHash(byte[] content) throws NoSuchAlgorithmException {
|
||||||
|
var digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
var hashBytes = digest.digest(content);
|
||||||
|
var hexString = new StringBuilder(2 * hashBytes.length);
|
||||||
|
|
||||||
|
for (byte b : hashBytes) {
|
||||||
|
var hex = Integer.toHexString(0xff & b);
|
||||||
|
|
||||||
|
if (hex.length() == 1) {
|
||||||
|
hexString.append('0');
|
||||||
|
}
|
||||||
|
|
||||||
|
hexString.append(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hexString.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/main/java/com/magamochi/image/service/ImageService.java
Normal file
54
src/main/java/com/magamochi/image/service/ImageService.java
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package com.magamochi.image.service;
|
||||||
|
|
||||||
|
import com.magamochi.common.exception.NotFoundException;
|
||||||
|
import com.magamochi.image.model.entity.Image;
|
||||||
|
import com.magamochi.image.model.repository.ImageRepository;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.apache.tika.mime.MimeTypeException;
|
||||||
|
import org.apache.tika.mime.MimeTypes;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ImageService {
|
||||||
|
private final S3Service s3Service;
|
||||||
|
private final ImageRepository imageRepository;
|
||||||
|
|
||||||
|
public UUID upload(byte[] data, String contentType, String path, String fileHash) {
|
||||||
|
var existingImage = imageRepository.findByFileHash(fileHash);
|
||||||
|
if (existingImage.isPresent()) {
|
||||||
|
log.info("Image already exists with hash {}, returning existing ID", fileHash);
|
||||||
|
return existingImage.get().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Uploading new image {} to S3", path);
|
||||||
|
|
||||||
|
String extension = "";
|
||||||
|
try {
|
||||||
|
extension = MimeTypes.getDefaultMimeTypes().forName(contentType).getExtension();
|
||||||
|
} catch (MimeTypeException e) {
|
||||||
|
log.warn("Could not determine extension for content type: {}", contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
var filename = "manga/" + path + "/" + UUID.randomUUID() + extension;
|
||||||
|
var objectKey = s3Service.uploadFile(data, contentType, filename);
|
||||||
|
|
||||||
|
return imageRepository
|
||||||
|
.save(Image.builder().objectKey(objectKey).fileHash(fileHash).build())
|
||||||
|
.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Image find(UUID id) {
|
||||||
|
return imageRepository
|
||||||
|
.findById(id)
|
||||||
|
.orElseThrow(() -> new NotFoundException("Image not found with ID " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Image> findAll() {
|
||||||
|
return imageRepository.findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,12 +1,11 @@
|
|||||||
package com.magamochi.mangamochi.service;
|
package com.magamochi.image.service;
|
||||||
|
|
||||||
import static java.util.Objects.nonNull;
|
import static java.util.Objects.nonNull;
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import lombok.Getter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@ -21,11 +20,13 @@ public class S3Service {
|
|||||||
@Value("${minio.bucket}")
|
@Value("${minio.bucket}")
|
||||||
private String bucket;
|
private String bucket;
|
||||||
|
|
||||||
|
@Value("${storage.base-url}")
|
||||||
|
@Getter
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
private final S3Client s3Client;
|
private final S3Client s3Client;
|
||||||
|
|
||||||
public String uploadFile(byte[] data, String contentType, String path) {
|
public String uploadFile(byte[] data, String contentType, String filename) {
|
||||||
var filename = "manga/" + path + "/" + UUID.randomUUID();
|
|
||||||
|
|
||||||
var request =
|
var request =
|
||||||
PutObjectRequest.builder().bucket(bucket).key(filename).contentType(contentType).build();
|
PutObjectRequest.builder().bucket(bucket).key(filename).contentType(contentType).build();
|
||||||
|
|
||||||
@ -34,10 +35,26 @@ public class S3Service {
|
|||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
public InputStream getFile(String key) {
|
public List<String> listAllObjectKeys() {
|
||||||
var request = GetObjectRequest.builder().bucket(bucket).key(key).build();
|
var keys = new ArrayList<String>();
|
||||||
|
String continuationToken = null;
|
||||||
|
|
||||||
return s3Client.getObject(request);
|
do {
|
||||||
|
var requestBuilder = ListObjectsV2Request.builder().bucket(bucket).maxKeys(1000);
|
||||||
|
|
||||||
|
if (nonNull(continuationToken)) {
|
||||||
|
requestBuilder.continuationToken(continuationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = s3Client.listObjectsV2(requestBuilder.build());
|
||||||
|
|
||||||
|
response.contents().forEach(s3Object -> keys.add(s3Object.key()));
|
||||||
|
|
||||||
|
continuationToken = response.isTruncated() ? response.nextContinuationToken() : null;
|
||||||
|
|
||||||
|
} while (nonNull(continuationToken));
|
||||||
|
|
||||||
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void deleteObjects(Set<String> objectKeys) {
|
public void deleteObjects(Set<String> objectKeys) {
|
||||||
@ -50,7 +67,7 @@ public class S3Service {
|
|||||||
objectKeys.stream().map(key -> ObjectIdentifier.builder().key(key).build()).toList();
|
objectKeys.stream().map(key -> ObjectIdentifier.builder().key(key).build()).toList();
|
||||||
|
|
||||||
for (int i = 0; i < allObjects.size(); i += BATCH_SIZE) {
|
for (int i = 0; i < allObjects.size(); i += BATCH_SIZE) {
|
||||||
int end = Math.min(i + BATCH_SIZE, allObjects.size());
|
var end = Math.min(i + BATCH_SIZE, allObjects.size());
|
||||||
List<ObjectIdentifier> batch = allObjects.subList(i, end);
|
List<ObjectIdentifier> batch = allObjects.subList(i, end);
|
||||||
|
|
||||||
DeleteObjectsRequest deleteRequest =
|
DeleteObjectsRequest deleteRequest =
|
||||||
@ -77,7 +94,6 @@ public class S3Service {
|
|||||||
+ (i / BATCH_SIZE + 1)
|
+ (i / BATCH_SIZE + 1)
|
||||||
+ ")");
|
+ ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (S3Exception e) {
|
} catch (S3Exception e) {
|
||||||
System.err.println(
|
System.err.println(
|
||||||
"Failed to delete batch starting at index "
|
"Failed to delete batch starting at index "
|
||||||
@ -87,26 +103,4 @@ public class S3Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<String> listAllObjectKeys() {
|
|
||||||
var keys = new ArrayList<String>();
|
|
||||||
String continuationToken = null;
|
|
||||||
|
|
||||||
do {
|
|
||||||
var requestBuilder = ListObjectsV2Request.builder().bucket(bucket).maxKeys(1000);
|
|
||||||
|
|
||||||
if (nonNull(continuationToken)) {
|
|
||||||
requestBuilder.continuationToken(continuationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = s3Client.listObjectsV2(requestBuilder.build());
|
|
||||||
|
|
||||||
response.contents().forEach(s3Object -> keys.add(s3Object.key()));
|
|
||||||
|
|
||||||
continuationToken = response.isTruncated() ? response.nextContinuationToken() : null;
|
|
||||||
|
|
||||||
} while (nonNull(continuationToken));
|
|
||||||
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user