refactor-architecture #27
41
pom.xml
41
pom.xml
@ -5,14 +5,14 @@
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.6</version>
|
||||
<version>4.0.3</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>com.magamochi</groupId>
|
||||
<groupId>com.mangamochi</groupId>
|
||||
<artifactId>mangamochi</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>mangamochi</name>
|
||||
<description>Demo project for Spring Boot</description>
|
||||
<description/>
|
||||
<url/>
|
||||
<licenses>
|
||||
<license/>
|
||||
@ -27,7 +27,7 @@
|
||||
<url/>
|
||||
</scm>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
@ -65,56 +65,49 @@
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
<version>2.34.5</version>
|
||||
<version>2.42.14</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>2.8.13</version>
|
||||
<version>3.0.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
<version>4.3.0</version>
|
||||
<version>5.0.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jsoup</groupId>
|
||||
<artifactId>jsoup</artifactId>
|
||||
<version>1.21.2</version>
|
||||
<version>1.22.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.hypersistence</groupId>
|
||||
<artifactId>hypersistence-utils-hibernate-63</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<artifactId>hypersistence-utils-hibernate-73</artifactId>
|
||||
<version>3.15.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>33.5.0-jre</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.13.0</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.13.0</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
@ -125,12 +118,12 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/io.github.resilience4j/resilience4j-spring-boot3 -->
|
||||
<dependency>
|
||||
<groupId>io.github.resilience4j</groupId>
|
||||
<artifactId>resilience4j-spring-boot3</artifactId>
|
||||
<version>2.3.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.tika</groupId>
|
||||
<artifactId>tika-core</artifactId>
|
||||
<version>3.2.3</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@ -162,7 +155,7 @@
|
||||
<plugin>
|
||||
<groupId>com.diffplug.spotless</groupId>
|
||||
<artifactId>spotless-maven-plugin</artifactId>
|
||||
<version>2.46.1</version>
|
||||
<version>3.3.0</version>
|
||||
<configuration>
|
||||
<java>
|
||||
<googleJavaFormat/>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.magamochi.mangamochi;
|
||||
package com.magamochi;
|
||||
|
||||
import org.springframework.amqp.rabbit.annotation.EnableRabbit;
|
||||
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;
|
||||
|
||||
@ -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;
|
||||
|
||||
public record AuthenticationResponseDTO(
|
||||
@ -1,4 +1,4 @@
|
||||
package com.magamochi.mangamochi.model.dto;
|
||||
package com.magamochi.authentication.model.dto;
|
||||
|
||||
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.nonNull;
|
||||
|
||||
import com.magamochi.mangamochi.service.CustomUserDetailsService;
|
||||
import com.magamochi.mangamochi.util.JwtUtil;
|
||||
import com.magamochi.user.service.CustomUserDetailsService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
@ -26,7 +25,7 @@ public class JwtRequestFilter extends OncePerRequestFilter {
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain)
|
||||
HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain chain)
|
||||
throws ServletException, IOException {
|
||||
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.Jwts;
|
||||
@ -1,8 +1,6 @@
|
||||
package com.magamochi.mangamochi.config;
|
||||
package com.magamochi.authentication.security;
|
||||
|
||||
import com.magamochi.mangamochi.security.JwtRequestFilter;
|
||||
import com.magamochi.mangamochi.service.CustomUserDetailsService;
|
||||
import com.magamochi.mangamochi.util.JwtUtil;
|
||||
import com.magamochi.user.service.CustomUserDetailsService;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
@ -27,18 +25,17 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
@EnableMethodSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final CustomUserDetailsService customUserDetailsService;
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@Bean
|
||||
public JwtRequestFilter jwtRequestFilter() {
|
||||
return new JwtRequestFilter(jwtUtil, userDetailsService);
|
||||
return new JwtRequestFilter(jwtUtil, customUserDetailsService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DaoAuthenticationProvider authenticationProvider() {
|
||||
var authProvider = new DaoAuthenticationProvider(userDetailsService);
|
||||
var authProvider = new DaoAuthenticationProvider(customUserDetailsService);
|
||||
authProvider.setPasswordEncoder(passwordEncoder());
|
||||
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.util.List;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
@ -9,7 +8,6 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
@FeignClient(name = "jikan", url = "https://api.jikan.moe/v4/manga")
|
||||
@Retry(name = "JikanRetry")
|
||||
public interface JikanClient {
|
||||
@GetMapping
|
||||
SearchResponse mangaSearch(@RequestParam String q);
|
||||
@ -30,10 +28,10 @@ public interface JikanClient {
|
||||
String title,
|
||||
List<String> title_synonyms,
|
||||
String status,
|
||||
boolean publishing,
|
||||
Boolean publishing,
|
||||
String synopsis,
|
||||
float score,
|
||||
int chapters,
|
||||
Float score,
|
||||
Integer chapters,
|
||||
PublishData published,
|
||||
List<AuthorData> authors,
|
||||
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.NotNull;
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
package com.magamochi.mangamochi.model.dto;
|
||||
package com.magamochi.catalog.model.dto;
|
||||
|
||||
import static java.util.Objects.isNull;
|
||||
|
||||
import com.magamochi.mangamochi.model.entity.Manga;
|
||||
import com.magamochi.mangamochi.model.entity.MangaAlternativeTitle;
|
||||
import com.magamochi.mangamochi.model.entity.MangaChapter;
|
||||
import com.magamochi.mangamochi.model.entity.MangaProvider;
|
||||
import com.magamochi.mangamochi.model.enumeration.ProviderStatus;
|
||||
import com.magamochi.catalog.model.entity.Manga;
|
||||
import com.magamochi.catalog.model.entity.MangaAlternativeTitle;
|
||||
import com.magamochi.catalog.model.entity.MangaContentProvider;
|
||||
import com.magamochi.catalog.model.enumeration.MangaStatus;
|
||||
import com.magamochi.content.model.entity.MangaContent;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.time.OffsetDateTime;
|
||||
@ -16,7 +16,7 @@ public record MangaDTO(
|
||||
@NotNull Long id,
|
||||
@NotBlank String title,
|
||||
String coverImageKey,
|
||||
String status,
|
||||
MangaStatus status,
|
||||
OffsetDateTime publishedFrom,
|
||||
OffsetDateTime publishedTo,
|
||||
String synopsis,
|
||||
@ -33,43 +33,43 @@ public record MangaDTO(
|
||||
return new MangaDTO(
|
||||
manga.getId(),
|
||||
manga.getTitle(),
|
||||
isNull(manga.getCoverImage()) ? null : manga.getCoverImage().getFileKey(),
|
||||
isNull(manga.getCoverImage()) ? null : manga.getCoverImage().getObjectKey(),
|
||||
manga.getStatus(),
|
||||
manga.getPublishedFrom(),
|
||||
manga.getPublishedTo(),
|
||||
manga.getSynopsis(),
|
||||
manga.getMangaProviders().size(),
|
||||
manga.getMangaContentProviders().size(),
|
||||
manga.getAlternativeTitles().stream().map(MangaAlternativeTitle::getTitle).toList(),
|
||||
manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(),
|
||||
manga.getMangaAuthors().stream()
|
||||
.map(mangaAuthor -> mangaAuthor.getAuthor().getName())
|
||||
.toList(),
|
||||
manga.getScore(),
|
||||
manga.getMangaProviders().stream().map(MangaProviderDTO::from).toList(),
|
||||
manga.getMangaContentProviders().stream().map(MangaProviderDTO::from).toList(),
|
||||
manga.getChapterCount(),
|
||||
favorite,
|
||||
following);
|
||||
}
|
||||
|
||||
public record MangaProviderDTO(
|
||||
@NotNull long id,
|
||||
long id,
|
||||
@NotBlank String providerName,
|
||||
@NotNull ProviderStatus providerStatus,
|
||||
boolean active,
|
||||
@NotNull Integer chaptersAvailable,
|
||||
@NotNull Integer chaptersDownloaded,
|
||||
@NotNull Boolean supportsChapterFetch) {
|
||||
public static MangaProviderDTO from(MangaProvider mangaProvider) {
|
||||
var chapters = mangaProvider.getMangaChapters();
|
||||
public static MangaProviderDTO from(MangaContentProvider mangaContentProvider) {
|
||||
var chapters = mangaContentProvider.getMangaContents();
|
||||
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(
|
||||
mangaProvider.getId(),
|
||||
mangaProvider.getProvider().getName(),
|
||||
mangaProvider.getProvider().getStatus(),
|
||||
mangaContentProvider.getId(),
|
||||
mangaContentProvider.getContentProvider().getName(),
|
||||
mangaContentProvider.getContentProvider().isActive(),
|
||||
chaptersAvailable,
|
||||
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.NotNull;
|
||||
import java.time.Instant;
|
||||
|
||||
public record ImportReviewDTO(
|
||||
public record MangaIngestReviewDTO(
|
||||
@NotNull Long id,
|
||||
@NotBlank String title,
|
||||
@NotBlank String providerName,
|
||||
@NotBlank String contentProviderName,
|
||||
String externalUrl,
|
||||
@NotBlank String reason,
|
||||
@NotNull Instant createdAt) {
|
||||
public static ImportReviewDTO from(MangaImportReview review) {
|
||||
return new ImportReviewDTO(
|
||||
public static MangaIngestReviewDTO from(MangaIngestReview review) {
|
||||
return new MangaIngestReviewDTO(
|
||||
review.getId(),
|
||||
review.getTitle(),
|
||||
review.getProvider().getName(),
|
||||
review.getMangaTitle(),
|
||||
review.getContentProvider().getName(),
|
||||
review.getUrl(),
|
||||
"Title match not found",
|
||||
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 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.NotNull;
|
||||
import java.time.OffsetDateTime;
|
||||
@ -12,7 +13,7 @@ public record MangaListDTO(
|
||||
@NotNull Long id,
|
||||
@NotBlank String title,
|
||||
String coverImageKey,
|
||||
String status,
|
||||
MangaStatus status,
|
||||
OffsetDateTime publishedFrom,
|
||||
OffsetDateTime publishedTo,
|
||||
Integer providerCount,
|
||||
@ -24,11 +25,11 @@ public record MangaListDTO(
|
||||
return new MangaListDTO(
|
||||
manga.getId(),
|
||||
manga.getTitle(),
|
||||
nonNull(manga.getCoverImage()) ? manga.getCoverImage().getFileKey() : null,
|
||||
nonNull(manga.getCoverImage()) ? manga.getCoverImage().getObjectKey() : null,
|
||||
manga.getStatus(),
|
||||
manga.getPublishedFrom(),
|
||||
manga.getPublishedTo(),
|
||||
manga.getMangaProviders().size(),
|
||||
manga.getMangaContentProviders().size(),
|
||||
manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(),
|
||||
manga.getMangaAuthors().stream()
|
||||
.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;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.magamochi.mangamochi.model.entity;
|
||||
package com.magamochi.catalog.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.Instant;
|
||||
@ -19,8 +19,6 @@ public class Author {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private Long malId;
|
||||
|
||||
private String name;
|
||||
|
||||
@CreationTimestamp private Instant createdAt;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.magamochi.mangamochi.model.entity;
|
||||
package com.magamochi.catalog.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.util.List;
|
||||
@ -16,8 +16,6 @@ public class Genre {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private Long malId;
|
||||
|
||||
private String name;
|
||||
|
||||
@OneToMany(mappedBy = "genre")
|
||||
@ -1,4 +1,4 @@
|
||||
package com.magamochi.mangamochi.model.entity;
|
||||
package com.magamochi.catalog.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
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 java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
@ -26,16 +30,13 @@ public class Manga {
|
||||
|
||||
private String title;
|
||||
|
||||
private String status;
|
||||
@Enumerated(EnumType.STRING)
|
||||
private MangaStatus status;
|
||||
|
||||
private String synopsis;
|
||||
|
||||
@CreationTimestamp private Instant createdAt;
|
||||
|
||||
@UpdateTimestamp private Instant updatedAt;
|
||||
|
||||
@OneToMany(mappedBy = "manga")
|
||||
private List<MangaProvider> mangaProviders;
|
||||
private List<MangaContentProvider> mangaContentProviders;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "cover_image_id")
|
||||
@ -47,6 +48,18 @@ public class Manga {
|
||||
|
||||
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")
|
||||
private List<MangaAuthor> mangaAuthors;
|
||||
|
||||
@ -58,8 +71,4 @@ public class Manga {
|
||||
|
||||
@OneToMany(mappedBy = "manga")
|
||||
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 lombok.*;
|
||||
@ -1,4 +1,4 @@
|
||||
package com.magamochi.mangamochi.model.entity;
|
||||
package com.magamochi.catalog.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
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 java.time.Instant;
|
||||
import java.util.List;
|
||||
@ -8,13 +10,13 @@ import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
@Entity
|
||||
@Table(name = "manga_provider")
|
||||
@Table(name = "manga_content_provider")
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class MangaProvider {
|
||||
public class MangaContentProvider {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
@ -24,15 +26,15 @@ public class MangaProvider {
|
||||
private Manga manga;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "provider_id", nullable = false)
|
||||
private Provider provider;
|
||||
@JoinColumn(name = "content_provider_id", nullable = false)
|
||||
private ContentProvider contentProvider;
|
||||
|
||||
private String mangaTitle;
|
||||
|
||||
private String url;
|
||||
|
||||
@OneToMany(mappedBy = "mangaProvider")
|
||||
List<MangaChapter> mangaChapters;
|
||||
@OneToMany(mappedBy = "mangaContentProvider")
|
||||
List<MangaContent> mangaContents;
|
||||
|
||||
@CreationTimestamp private Instant createdAt;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.magamochi.mangamochi.model.entity;
|
||||
package com.magamochi.catalog.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
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 java.time.Instant;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
@Entity
|
||||
@Table(name = "manga_import_reviews")
|
||||
@Table(name = "manga_ingest_reviews")
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class MangaImportReview {
|
||||
public class MangaIngestReview {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String title;
|
||||
private String mangaTitle;
|
||||
|
||||
private String url;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "provider_id")
|
||||
private Provider provider;
|
||||
@JoinColumn(name = "content_provider_id")
|
||||
private ContentProvider contentProvider;
|
||||
|
||||
@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 {
|
||||
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 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;
|
||||
|
||||
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.mangamochi.model.entity.Manga;
|
||||
import com.magamochi.mangamochi.model.entity.MangaAuthor;
|
||||
import com.magamochi.catalog.model.entity.Author;
|
||||
import com.magamochi.catalog.model.entity.Manga;
|
||||
import com.magamochi.catalog.model.entity.MangaAuthor;
|
||||
import java.util.Optional;
|
||||
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 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.Optional;
|
||||
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.mangamochi.model.repository.GenreRepository;
|
||||
import com.magamochi.catalog.model.dto.GenreDTO;
|
||||
import com.magamochi.catalog.model.repository.GenreRepository;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.mangamochi.model.entity.Language;
|
||||
import com.magamochi.mangamochi.model.repository.LanguageRepository;
|
||||
import com.magamochi.catalog.model.entity.Language;
|
||||
import com.magamochi.catalog.model.repository.LanguageRepository;
|
||||
import com.magamochi.common.exception.NotFoundException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@ -11,7 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
public class LanguageService {
|
||||
public final LanguageRepository languageRepository;
|
||||
|
||||
public Language getOrThrow(String code) {
|
||||
public Language find(String code) {
|
||||
return languageRepository
|
||||
.findByCodeIgnoreCase(code)
|
||||
.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.springframework.util.CollectionUtils.isEmpty;
|
||||
|
||||
import com.magamochi.mangamochi.model.dto.TitleMatchRequestDTO;
|
||||
import com.magamochi.mangamochi.model.dto.TitleMatchResponseDTO;
|
||||
import lombok.Builder;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.apache.commons.text.similarity.LevenshteinDistance;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -14,18 +13,24 @@ import org.springframework.stereotype.Service;
|
||||
public class TitleMatcherService {
|
||||
private final LevenshteinDistance levenshteinDistance = LevenshteinDistance.getDefaultInstance();
|
||||
|
||||
public TitleMatchResponseDTO findBestMatch(TitleMatchRequestDTO request) {
|
||||
if (isBlank(request.getTitle()) || isEmpty(request.getOptions())) {
|
||||
public TitleMatchResponse findBestMatch(TitleMatchRequest request) {
|
||||
if (isBlank(request.title()) || !request.options().iterator().hasNext()) {
|
||||
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;
|
||||
double bestScore = 0.0;
|
||||
|
||||
for (var option : request.getOptions()) {
|
||||
var score = calculateSimilarityScore(request.getTitle(), option);
|
||||
for (var option : request.options()) {
|
||||
var score = calculateSimilarityScore(request.title(), option);
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
@ -33,20 +38,20 @@ public class TitleMatcherService {
|
||||
}
|
||||
}
|
||||
|
||||
if (bestScore >= request.getThreshold()) {
|
||||
if (bestScore >= threshold) {
|
||||
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)
|
||||
.bestMatch(bestMatch)
|
||||
.similarity(bestScore)
|
||||
.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) {
|
||||
@ -64,4 +69,10 @@ public class TitleMatcherService {
|
||||
// Format to two decimal places for a cleaner result
|
||||
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.RoundingMode;
|
||||
@ -1,14 +1,12 @@
|
||||
package com.magamochi.mangamochi.client;
|
||||
package com.magamochi.client;
|
||||
|
||||
import com.magamochi.mangamochi.model.dto.MangaDexMangaDTO;
|
||||
import io.github.resilience4j.retry.annotation.Retry;
|
||||
import com.magamochi.model.dto.MangaDexMangaDTO;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@FeignClient(name = "mangaDex", url = "https://api.mangadex.org")
|
||||
@Retry(name = "MangaDexRetry")
|
||||
public interface MangaDexClient {
|
||||
@GetMapping("/manga/{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.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@FeignClient(name = "ntfy", url = "${ntfy.endpoint}")
|
||||
@Retry(name = "JikanRetry")
|
||||
public interface NtfyClient {
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
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 org.springframework.context.annotation.Bean;
|
||||
@ -16,6 +16,11 @@ public class RateLimiterConfig {
|
||||
return RateLimiter.create(1);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RateLimiter aniListRateLimiter() {
|
||||
return RateLimiter.create(0.5);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RateLimiter imageDownloadRateLimiter() {
|
||||
return RateLimiter.create(10);
|
||||
@ -1,4 +1,4 @@
|
||||
package com.magamochi.mangamochi.config;
|
||||
package com.magamochi.common.config;
|
||||
|
||||
import lombok.NonNull;
|
||||
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 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 org.springframework.http.HttpStatus;
|
||||
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 NotFoundException(String message) {
|
||||
@ -1,4 +1,4 @@
|
||||
package com.magamochi.mangamochi.exception;
|
||||
package com.magamochi.common.exception;
|
||||
|
||||
public class UnprocessableException extends RuntimeException {
|
||||
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 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.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 com.magamochi.mangamochi.model.entity.Language;
|
||||
import com.magamochi.catalog.model.entity.Language;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
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 java.time.Instant;
|
||||
import java.util.List;
|
||||
@ -8,20 +11,22 @@ import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
@Entity
|
||||
@Table(name = "manga_chapters")
|
||||
@Table(name = "manga_contents")
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class MangaChapter {
|
||||
public class MangaContent {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "manga_provider_id")
|
||||
private MangaProvider mangaProvider;
|
||||
@JoinColumn(name = "manga_content_provider_id")
|
||||
private MangaContentProvider mangaContentProvider;
|
||||
|
||||
@Builder.Default private ContentType type = ContentType.CHAPTER;
|
||||
|
||||
private String title;
|
||||
|
||||
@ -29,16 +34,12 @@ public class MangaChapter {
|
||||
|
||||
@Builder.Default private Boolean downloaded = false;
|
||||
|
||||
@Builder.Default private Boolean read = false;
|
||||
|
||||
@CreationTimestamp private Instant createdAt;
|
||||
|
||||
@UpdateTimestamp private Instant updatedAt;
|
||||
|
||||
@OneToMany(mappedBy = "mangaChapter")
|
||||
private List<MangaChapterImage> mangaChapterImages;
|
||||
|
||||
private Integer chapterNumber;
|
||||
@OneToMany(mappedBy = "mangaContent")
|
||||
private List<MangaContentImage> mangaContentImages;
|
||||
|
||||
@ManyToOne
|
||||
@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 java.time.Instant;
|
||||
import lombok.*;
|
||||
@ -7,20 +8,20 @@ import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
@Entity
|
||||
@Table(name = "manga_chapter_images")
|
||||
@Table(name = "manga_content_images")
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class MangaChapterImage {
|
||||
public class MangaContentImage {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "manga_chapter_id")
|
||||
private MangaChapter mangaChapter;
|
||||
@JoinColumn(name = "manga_content_id")
|
||||
private MangaContent mangaContent;
|
||||
|
||||
@OneToOne
|
||||
@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.mangamochi.model.enumeration.ArchiveFileType;
|
||||
import com.magamochi.mangamochi.service.MangaChapterService;
|
||||
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||
import com.magamochi.model.enumeration.ArchiveFileType;
|
||||
import com.magamochi.service.MangaChapterService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@ -20,30 +20,6 @@ import org.springframework.web.bind.annotation.*;
|
||||
public class MangaChapterController {
|
||||
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(
|
||||
summary = "Mark a chapter as read",
|
||||
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.mangamochi.service.MangaImportService;
|
||||
import com.magamochi.mangamochi.service.ProviderManualMangaImportService;
|
||||
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||
import com.magamochi.model.dto.ImportMangaResponseDTO;
|
||||
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.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
@ -18,7 +20,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
@RequestMapping("/manga/import")
|
||||
@RequiredArgsConstructor
|
||||
public class MangaImportController {
|
||||
private final MangaImportService mangaImportService;
|
||||
// private final MangaImportService mangaImportService;
|
||||
private final ProviderManualMangaImportService providerManualMangaImportService;
|
||||
|
||||
@Operation(
|
||||
@ -53,7 +55,7 @@ public class MangaImportController {
|
||||
@RequestPart("files")
|
||||
@NotNull
|
||||
List<MultipartFile> files) {
|
||||
mangaImportService.importMangaFiles(malId, files);
|
||||
// mangaImportService.importMangaFiles(malId, files);
|
||||
|
||||
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.mangamochi.service.UserFavoriteMangaService;
|
||||
import com.magamochi.common.model.dto.DefaultResponseDTO;
|
||||
import com.magamochi.service.UserFavoriteMangaService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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 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 java.time.Instant;
|
||||
@ -19,7 +19,9 @@ public class Image {
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
private String fileKey;
|
||||
private String objectKey;
|
||||
|
||||
private String fileHash;
|
||||
|
||||
@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 java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -21,11 +20,13 @@ public class S3Service {
|
||||
@Value("${minio.bucket}")
|
||||
private String bucket;
|
||||
|
||||
@Value("${storage.base-url}")
|
||||
@Getter
|
||||
private String baseUrl;
|
||||
|
||||
private final S3Client s3Client;
|
||||
|
||||
public String uploadFile(byte[] data, String contentType, String path) {
|
||||
var filename = "manga/" + path + "/" + UUID.randomUUID();
|
||||
|
||||
public String uploadFile(byte[] data, String contentType, String filename) {
|
||||
var request =
|
||||
PutObjectRequest.builder().bucket(bucket).key(filename).contentType(contentType).build();
|
||||
|
||||
@ -34,10 +35,26 @@ public class S3Service {
|
||||
return filename;
|
||||
}
|
||||
|
||||
public InputStream getFile(String key) {
|
||||
var request = GetObjectRequest.builder().bucket(bucket).key(key).build();
|
||||
public List<String> listAllObjectKeys() {
|
||||
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) {
|
||||
@ -50,7 +67,7 @@ public class S3Service {
|
||||
objectKeys.stream().map(key -> ObjectIdentifier.builder().key(key).build()).toList();
|
||||
|
||||
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);
|
||||
|
||||
DeleteObjectsRequest deleteRequest =
|
||||
@ -77,7 +94,6 @@ public class S3Service {
|
||||
+ (i / BATCH_SIZE + 1)
|
||||
+ ")");
|
||||
}
|
||||
|
||||
} catch (S3Exception e) {
|
||||
System.err.println(
|
||||
"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