refactor-architecture #27

Merged
rov merged 11 commits from refactor-architecture into main 2026-03-18 16:55:37 -03:00
240 changed files with 3973 additions and 3193 deletions

37
pom.xml
View File

@ -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,11 +118,11 @@
<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>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>3.2.3</version>
<scope>compile</scope>
</dependency>
</dependencies>
@ -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/>

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi;
package com.magamochi;
import org.springframework.amqp.rabbit.annotation.EnableRabbit;
import org.springframework.boot.SpringApplication;

View File

@ -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));
}
}

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.model.dto;
package com.magamochi.authentication.model.dto;
import jakarta.validation.constraints.NotNull;

View File

@ -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(

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.model.dto;
package com.magamochi.authentication.model.dto;
import jakarta.validation.constraints.NotNull;

View File

@ -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");

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.util;
package com.magamochi.authentication.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;

View File

@ -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;
}

View File

@ -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());
}
}

View File

@ -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) {}
}
}
}
}
}

View File

@ -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) {

View File

@ -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));
}
}

View File

@ -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();
}
}

View File

@ -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;

View File

@ -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());
}
}
}

View File

@ -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) {}

View File

@ -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());

View File

@ -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())

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.model.dto;
package com.magamochi.catalog.model.dto;
import java.util.List;

View File

@ -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;

View File

@ -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")

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.model.entity;
package com.magamochi.catalog.model.entity;
import jakarta.persistence.*;
import lombok.*;

View File

@ -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;
}

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.model.entity;
package com.magamochi.catalog.model.entity;
import jakarta.persistence.*;
import lombok.*;

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.model.entity;
package com.magamochi.catalog.model.entity;
import jakarta.persistence.*;
import lombok.*;

View File

@ -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;

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.model.entity;
package com.magamochi.catalog.model.entity;
import jakarta.persistence.*;
import lombok.*;

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
package com.magamochi.catalog.model.enumeration;
public enum MangaState {
PENDING,
AVAILABLE,
}

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.model.enumeration;
package com.magamochi.catalog.model.enumeration;
public enum MangaStatus {
ONGOING,

View File

@ -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> {}

View File

@ -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> {}

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -0,0 +1,3 @@
package com.magamochi.catalog.queue.command;
public record MangaUpdateCommand(long mangaId) {}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View 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");
}
}

View File

@ -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;

View File

@ -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"));

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}
}

View File

@ -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) {}
}

View File

@ -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()));
}
}

View File

@ -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);
}
}

View File

@ -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;
};
}
}

View File

@ -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) {}
}

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.util;
package com.magamochi.catalog.util;
import java.math.BigDecimal;
import java.math.RoundingMode;

View File

@ -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);

View File

@ -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);

View 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;
}
}

View File

@ -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);

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.config;
package com.magamochi.common.config;
import lombok.NonNull;
import org.springframework.context.annotation.Configuration;

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.exception;
package com.magamochi.common.exception;
public class ConflictException extends RuntimeException {
public ConflictException(String message) {

View File

@ -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;

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.exception;
package com.magamochi.common.exception;
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) {

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.exception;
package com.magamochi.common.exception;
public class UnprocessableException extends RuntimeException {
public UnprocessableException(String message) {

View File

@ -1,4 +1,4 @@
package com.magamochi.mangamochi.model.dto;
package com.magamochi.common.model.dto;
import jakarta.annotation.Nullable;
import java.time.Instant;

View File

@ -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;

View File

@ -0,0 +1,8 @@
package com.magamochi.common.model.enumeration;
public enum ContentType {
MANGA_COVER,
CHAPTER,
VOLUME,
CONTENT_IMAGE,
}

View File

@ -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) {}

View File

@ -0,0 +1,5 @@
package com.magamochi.common.queue.command;
import java.util.UUID;
public record ImageUpdateCommand(long entityId, UUID imageId) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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;

View File

@ -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()));
}
}

View File

@ -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")

View File

@ -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")

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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.",

View 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();
}
}

View File

@ -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();
}

View File

@ -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.*;

View File

@ -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;

View File

@ -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;

View File

@ -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);
}

View File

@ -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());
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View 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();
}
}

View File

@ -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