refactor-architecture #27

Merged
rov merged 11 commits from refactor-architecture into main 2026-03-18 16:55:37 -03:00
95 changed files with 1688 additions and 1288 deletions
Showing only changes of commit 0feae82a9b - Show all commits

View File

@ -145,6 +145,8 @@
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
<source>22</source>
<target>22</target>
</configuration>
</plugin>
<plugin>

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

@ -1,11 +1,20 @@
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.common.dto.DefaultResponseDTO;
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
@ -13,6 +22,7 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class CatalogController {
private final GenreService genreService;
private final MangaService mangaService;
@Operation(
summary = "Get a list of manga genres",
@ -23,4 +33,26 @@ public class CatalogController {
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,9 +1,10 @@
package com.magamochi.model.dto;
package com.magamochi.catalog.model.dto;
import static java.util.Objects.isNull;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.entity.MangaAlternativeTitle;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.entity.MangaAlternativeTitle;
import com.magamochi.catalog.model.enumeration.MangaStatus;
import com.magamochi.model.entity.MangaChapter;
import com.magamochi.model.entity.MangaContentProvider;
import jakarta.validation.constraints.NotBlank;
@ -15,7 +16,7 @@ public record MangaDTO(
@NotNull Long id,
@NotBlank String title,
String coverImageKey,
String status,
MangaStatus status,
OffsetDateTime publishedFrom,
OffsetDateTime publishedTo,
String synopsis,

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,21 +1,21 @@
package com.magamochi.model.dto;
package com.magamochi.catalog.model.dto;
import com.magamochi.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.getMangaTitle(),
review.getContentProvider().getName(),
review.getUrl(),
"Title match not found",

View File

@ -1,8 +1,9 @@
package com.magamochi.model.dto;
package com.magamochi.catalog.model.dto;
import static java.util.Objects.nonNull;
import com.magamochi.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,

View File

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

View File

@ -1,4 +1,4 @@
package com.magamochi.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,6 +1,5 @@
package com.magamochi.catalog.model.entity;
import com.magamochi.model.entity.MangaGenre;
import jakarta.persistence.*;
import java.util.List;
import lombok.*;
@ -17,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.model.entity;
package com.magamochi.catalog.model.entity;
import jakarta.persistence.*;
import lombok.*;

View File

@ -1,5 +1,10 @@
package com.magamochi.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.model.entity.Image;
import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.entity.UserFavoriteManga;
import jakarta.persistence.*;
import java.time.Instant;
import java.time.OffsetDateTime;
@ -26,14 +31,11 @@ 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<MangaContentProvider> mangaContentProviders;
@ -47,6 +49,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 +72,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.model.entity;
package com.magamochi.catalog.model.entity;
import jakarta.persistence.*;
import lombok.*;

View File

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

View File

@ -1,6 +1,5 @@
package com.magamochi.model.entity;
package com.magamochi.catalog.model.entity;
import com.magamochi.catalog.model.entity.Genre;
import jakarta.persistence.*;
import lombok.*;

View File

@ -1,4 +1,4 @@
package com.magamochi.model.entity;
package com.magamochi.catalog.model.entity;
import com.magamochi.ingestion.model.entity.ContentProvider;
import jakarta.persistence.*;
@ -7,18 +7,18 @@ 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;

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

@ -1,11 +1,6 @@
package com.magamochi.catalog.model.repository;
import com.magamochi.catalog.model.entity.Genre;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GenreRepository extends JpaRepository<Genre, Long> {
Optional<Genre> findByMalId(Long malId);
Optional<Genre> findByName(String name);
}
public interface GenreRepository extends JpaRepository<Genre, Long> {}

View File

@ -1,6 +1,6 @@
package com.magamochi.model.repository;
package com.magamochi.catalog.model.repository;
import com.magamochi.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.model.repository;
package com.magamochi.catalog.model.repository;
import com.magamochi.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.model.repository;
package com.magamochi.catalog.model.repository;
import com.magamochi.model.entity.Author;
import com.magamochi.model.entity.Manga;
import com.magamochi.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.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,8 +1,8 @@
package com.magamochi.model.repository;
package com.magamochi.catalog.model.repository;
import com.magamochi.catalog.model.entity.Genre;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.entity.MangaGenre;
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.model.repository;
package com.magamochi.catalog.model.repository;
import com.magamochi.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.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,8 +1,8 @@
package com.magamochi.service;
package com.magamochi.catalog.service;
import com.magamochi.catalog.model.entity.Language;
import com.magamochi.catalog.model.repository.LanguageRepository;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.model.entity.Language;
import com.magamochi.model.repository.LanguageRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

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.MangaIngestReview;
import com.magamochi.catalog.model.repository.MangaContentProviderRepository;
import com.magamochi.catalog.model.repository.MangaIngestReviewRepository;
import com.magamochi.ingestion.service.ContentProviderService;
import com.magamochi.model.entity.MangaContentProvider;
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.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,91 @@
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 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 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);
// manga.setCoverImageId(imageId);
//
// log.info("Manga with ID {} cover image updated successfully", mangaId);
}
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.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.model.dto.TitleMatchRequestDTO;
import com.magamochi.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.util;
package com.magamochi.catalog.util;
import java.math.BigDecimal;
import java.math.RoundingMode;

View File

@ -1,51 +0,0 @@
package com.magamochi.client;
import io.github.resilience4j.retry.annotation.Retry;
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")
@Retry(name = "AniListRetry")
public interface AniListClient {
@PostMapping
MangaResponse getManga(@RequestBody GraphQLRequest request);
record GraphQLRequest(String query, Variables variables) {
public record Variables(Long id) {}
}
record MangaResponse(Data data) {
public record Data(Manga Media) {}
public record Manga(
Long id,
Long idMal,
Title title,
String status,
String description, // synopsis
int chapters,
int averageScore, // score (0-100)
CoverImage coverImage,
List<String> genres,
FuzzyDate startDate,
FuzzyDate endDate,
StaffConnection staff) {
public record Title(String romaji, String english, 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

@ -16,6 +16,22 @@ public class RabbitConfig {
@Value("${queues.provider-page-ingest}")
private String providerPageIngestQueue;
@Value("${queues.manga-update}")
private String mangaUpdateQueue;
@Value("${queues.image-fetch}")
private String imageFetchQueue;
@Bean
public Queue imageFetchQueue() {
return new Queue(imageFetchQueue, false);
}
@Bean
public Queue mangaUpdateQueue() {
return new Queue(mangaUpdateQueue, false);
}
@Bean
public Queue mangaIngestQueue() {
return new Queue(mangaIngestQueue, false);

View File

@ -1,6 +1,6 @@
package com.magamochi.common.exception;
import com.magamochi.common.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.common.dto;
package com.magamochi.common.model.dto;
import jakarta.annotation.Nullable;
import java.time.Instant;

View File

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

View File

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

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

@ -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,6 +1,6 @@
package com.magamochi.controller;
import com.magamochi.common.dto.DefaultResponseDTO;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.model.dto.AuthenticationRequestDTO;
import com.magamochi.model.dto.AuthenticationResponseDTO;
import com.magamochi.model.dto.RefreshTokenRequestDTO;

View File

@ -1,11 +1,9 @@
package com.magamochi.controller;
import com.magamochi.client.NtfyClient;
import com.magamochi.common.dto.DefaultResponseDTO;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.ingestion.task.IngestFromContentProvidersTask;
import com.magamochi.model.dto.UpdateMangaDataCommand;
import com.magamochi.model.repository.UserRepository;
import com.magamochi.queue.UpdateMangaDataProducer;
import com.magamochi.task.ImageCleanupTask;
import com.magamochi.task.MangaFollowUpdateTask;
import io.swagger.v3.oas.annotations.Operation;
@ -21,19 +19,6 @@ public class ManagementController {
private final MangaFollowUpdateTask mangaFollowUpdateTask;
private final UserRepository userRepository;
private final NtfyClient ntfyClient;
private final UpdateMangaDataProducer updateMangaDataProducer;
@Operation(
summary = "Trigger manga data update",
description = "Triggers the update of the metadata for a manga by its ID",
tags = {"Management"},
operationId = "triggerUpdateMangaData")
@PostMapping("update-manga-data/{mangaId}")
public DefaultResponseDTO<Void> triggerUpdateMangaData(@PathVariable Long mangaId) {
updateMangaDataProducer.sendUpdateMangaDataCommand(new UpdateMangaDataCommand(mangaId));
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Cleanup unused S3 images",

View File

@ -1,6 +1,6 @@
package com.magamochi.controller;
import com.magamochi.common.dto.DefaultResponseDTO;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.model.dto.MangaChapterImagesDTO;
import com.magamochi.model.enumeration.ArchiveFileType;
import com.magamochi.service.MangaChapterService;

View File

@ -1,48 +1,18 @@
package com.magamochi.controller;
import com.magamochi.common.dto.DefaultResponseDTO;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.model.dto.MangaChapterDTO;
import com.magamochi.model.dto.MangaDTO;
import com.magamochi.model.dto.MangaListDTO;
import com.magamochi.model.dto.MangaListFilterDTO;
import com.magamochi.service.MangaService;
import com.magamochi.service.OldMangaService;
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("/mangas")
@RequiredArgsConstructor
public class MangaController {
private final MangaService mangaService;
@Operation(
summary = "Get a list of mangas",
description = "Retrieve a list of mangas with their details.",
tags = {"Manga"},
operationId = "getMangas")
@GetMapping
public DefaultResponseDTO<Page<MangaListDTO>> getMangas(
@ParameterObject MangaListFilterDTO filterDTO,
@Parameter(hidden = true) @ParameterObject @PageableDefault Pageable pageable) {
return DefaultResponseDTO.ok(mangaService.getMangas(filterDTO, pageable));
}
@Operation(
summary = "Get the details of a manga",
description = "Get the details of a manga by its ID",
tags = {"Manga"},
operationId = "getManga")
@GetMapping("/{mangaId}")
public DefaultResponseDTO<MangaDTO> getManga(@PathVariable Long mangaId) {
return DefaultResponseDTO.ok(mangaService.getManga(mangaId));
}
private final OldMangaService oldMangaService;
@Operation(
summary = "Get the available chapters for a specific manga/provider combination",
@ -52,7 +22,7 @@ public class MangaController {
@GetMapping("/{mangaProviderId}/chapters")
public DefaultResponseDTO<List<MangaChapterDTO>> getMangaChapters(
@PathVariable Long mangaProviderId) {
return DefaultResponseDTO.ok(mangaService.getMangaChapters(mangaProviderId));
return DefaultResponseDTO.ok(oldMangaService.getMangaChapters(mangaProviderId));
}
@Operation(
@ -62,7 +32,7 @@ public class MangaController {
operationId = "fetchAllChapters")
@PostMapping(value = "/{mangaProviderId}/fetch-all-chapters")
public DefaultResponseDTO<Void> fetchAllChapters(@PathVariable Long mangaProviderId) {
mangaService.fetchAllNotDownloadedChapters(mangaProviderId);
oldMangaService.fetchAllNotDownloadedChapters(mangaProviderId);
return DefaultResponseDTO.ok().build();
}
@ -74,7 +44,7 @@ public class MangaController {
operationId = "fetchMangaChapters")
@PostMapping("/{mangaProviderId}/fetch-chapters")
public DefaultResponseDTO<Void> fetchMangaChapters(@PathVariable Long mangaProviderId) {
mangaService.fetchMangaChapters(mangaProviderId);
oldMangaService.fetchMangaChapters(mangaProviderId);
return DefaultResponseDTO.ok().build();
}
@ -86,7 +56,7 @@ public class MangaController {
operationId = "followManga")
@PostMapping("/{mangaId}/followManga")
public DefaultResponseDTO<Void> followManga(@PathVariable Long mangaId) {
mangaService.follow(mangaId);
oldMangaService.follow(mangaId);
return DefaultResponseDTO.ok().build();
}
@ -98,7 +68,7 @@ public class MangaController {
operationId = "unfollowManga")
@PostMapping("/{mangaId}/unfollowManga")
public DefaultResponseDTO<Void> unfollowManga(@PathVariable Long mangaId) {
mangaService.unfollow(mangaId);
oldMangaService.unfollow(mangaId);
return DefaultResponseDTO.ok().build();
}

View File

@ -1,9 +1,9 @@
package com.magamochi.controller;
import com.magamochi.common.dto.DefaultResponseDTO;
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.MangaImportService;
import com.magamochi.service.ProviderManualMangaImportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -20,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(
@ -55,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,51 +0,0 @@
package com.magamochi.controller;
import com.magamochi.common.dto.DefaultResponseDTO;
import com.magamochi.model.dto.ImportReviewDTO;
import com.magamochi.service.MangaImportReviewService;
import io.swagger.v3.oas.annotations.Operation;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/manga/import/review")
@RequiredArgsConstructor
public class MangaImportReviewController {
private final MangaImportReviewService mangaImportReviewService;
@Operation(
summary = "Get list of pending import reviews",
description = "Get list of pending import reviews.",
tags = {"Manga Import Review"},
operationId = "getImportReviews")
@GetMapping
public DefaultResponseDTO<List<ImportReviewDTO>> getImportReviews() {
return DefaultResponseDTO.ok(mangaImportReviewService.getImportReviews());
}
@Operation(
summary = "Delete pending import review",
description = "Delete pending import review by ID.",
tags = {"Manga Import Review"},
operationId = "deleteImportReview")
@DeleteMapping("/{id}")
public DefaultResponseDTO<Void> deleteImportReview(@PathVariable Long id) {
mangaImportReviewService.deleteImportReview(id);
return DefaultResponseDTO.ok().build();
}
@Operation(
summary = "Resolve import review",
description = "Resolve import review by ID.",
tags = {"Manga Import Review"},
operationId = "resolveImportReview")
@PostMapping
public DefaultResponseDTO<Void> resolveImportReview(
@RequestParam Long importReviewId, @RequestParam String malId) {
mangaImportReviewService.resolveImportReview(importReviewId, malId);
return DefaultResponseDTO.ok().build();
}
}

View File

@ -1,6 +1,6 @@
package com.magamochi.controller;
import com.magamochi.common.dto.DefaultResponseDTO;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.service.UserFavoriteMangaService;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;

View File

@ -1,6 +1,6 @@
package com.magamochi.ingestion.controller;
import com.magamochi.common.dto.DefaultResponseDTO;
import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.ingestion.model.dto.ContentProviderListDTO;
import com.magamochi.ingestion.service.ContentProviderService;
import com.magamochi.ingestion.service.IngestionService;

View File

@ -1,6 +1,5 @@
package com.magamochi.ingestion.model.dto;
import com.magamochi.model.enumeration.MangaStatus;
import jakarta.validation.constraints.NotBlank;
public record MangaInfoDTO(@NotBlank String title, @NotBlank String url, MangaStatus status) {}
public record MangaInfoDTO(@NotBlank String title, @NotBlank String url) {}

View File

@ -6,7 +6,6 @@ import com.magamochi.ingestion.providers.ContentProviders;
import com.magamochi.ingestion.providers.PagedContentProvider;
import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.enumeration.MangaStatus;
import java.io.IOException;
import java.util.*;
import java.util.regex.Pattern;
@ -116,25 +115,13 @@ public class MangaLivreBlogProvider implements ContentProvider, PagedContentProv
try {
var linkElement = element.getElementsByTag("a").getFirst();
var imageContainer =
linkElement.getElementsByClass("manga-card-image").getFirst();
var contentContainer =
linkElement.getElementsByClass("manga-card-content").getFirst();
var title = contentContainer.getElementsByTag("h3").text();
var url = linkElement.attr("href");
var status =
switch (imageContainer
.getElementsByClass("manga-status")
.text()
.toLowerCase()) {
case "em andamento" -> MangaStatus.ONGOING;
case "completo" -> MangaStatus.COMPLETED;
case "hiato" -> MangaStatus.HIATUS;
default -> MangaStatus.UNKNOWN;
};
return new MangaInfoDTO(title, url, status);
return new MangaInfoDTO(title, url);
} catch (Exception e) {
return null;
}

View File

@ -9,7 +9,6 @@ import com.magamochi.ingestion.providers.PagedContentProvider;
import com.magamochi.ingestion.service.FlareService;
import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.enumeration.MangaStatus;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@ -116,7 +115,7 @@ public class MangaLivreProvider implements ContentProvider, PagedContentProvider
}
}
return new MangaInfoDTO(title.trim(), url.trim(), MangaStatus.UNKNOWN);
return new MangaInfoDTO(title.trim(), url.trim());
})
.toList();
} catch (NoSuchElementException e) {

View File

@ -10,7 +10,6 @@ import com.magamochi.ingestion.providers.PagedContentProvider;
import com.magamochi.ingestion.service.FlareService;
import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.enumeration.MangaStatus;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@ -142,7 +141,7 @@ public class PinkRosaScanProvider implements ContentProvider, PagedContentProvid
var textElement = linkElement.getElementsByTag("h3");
var title = textElement.text().trim();
return new MangaInfoDTO(title, url, MangaStatus.UNKNOWN);
return new MangaInfoDTO(title, url);
})
.toList();
} catch (NoSuchElementException e) {

View File

@ -2,7 +2,7 @@ package com.magamochi.model.dto;
import static java.util.Objects.isNull;
import com.magamochi.model.entity.Language;
import com.magamochi.catalog.model.entity.Language;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

View File

@ -1,3 +0,0 @@
package com.magamochi.model.dto;
public record MangaListUpdateCommand(String contentProviderName, Integer page) {}

View File

@ -1,7 +0,0 @@
package com.magamochi.model.dto;
import com.magamochi.ingestion.model.dto.MangaInfoDTO;
import java.util.List;
public record MangaMessageDTO(
String contentProviderName, List<MangaInfoDTO> mangaInfoResponseDTOs) {}

View File

@ -1,15 +0,0 @@
package com.magamochi.model.dto;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class TitleMatchRequestDTO {
private String title;
private List<String> options;
@Builder.Default private int threshold = 85;
}

View File

@ -1,12 +0,0 @@
package com.magamochi.model.dto;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class TitleMatchResponseDTO {
boolean matchFound;
String bestMatch;
Double similarity;
}

View File

@ -1,3 +0,0 @@
package com.magamochi.model.dto;
public record UpdateMangaDataCommand(Long mangaId) {}

View File

@ -1,5 +1,6 @@
package com.magamochi.model.entity;
import com.magamochi.catalog.model.entity.Language;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.List;

View File

@ -1,5 +1,6 @@
package com.magamochi.model.entity;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.ingestion.model.entity.ContentProvider;
import jakarta.persistence.*;
import java.time.Instant;

View File

@ -1,5 +1,6 @@
package com.magamochi.model.entity;
import com.magamochi.catalog.model.entity.Manga;
import jakarta.persistence.*;
import java.time.Instant;
import lombok.*;

View File

@ -1,5 +1,6 @@
package com.magamochi.model.entity;
import com.magamochi.catalog.model.entity.Manga;
import jakarta.persistence.*;
import lombok.*;

View File

@ -1,11 +0,0 @@
package com.magamochi.model.repository;
import com.magamochi.model.entity.Author;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorRepository extends JpaRepository<Author, Long> {
Optional<Author> findByMalId(Long aLong);
Optional<Author> findByName(String name);
}

View File

@ -1,15 +0,0 @@
package com.magamochi.model.repository;
import com.magamochi.ingestion.model.entity.ContentProvider;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.entity.MangaContentProvider;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaContentProviderRepository extends JpaRepository<MangaContentProvider, Long> {
boolean existsByMangaAndContentProviderAndUrlIgnoreCase(
Manga manga, ContentProvider contentProvider, String url);
Optional<MangaContentProvider> findByMangaTitleIgnoreCaseAndContentProvider(
String mangaTitle, ContentProvider contentProvider);
}

View File

@ -1,8 +0,0 @@
package com.magamochi.model.repository;
import com.magamochi.model.entity.MangaImportReview;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaImportReviewRepository extends JpaRepository<MangaImportReview, Long> {
boolean existsByTitleIgnoreCaseAndUrlIgnoreCase(String title, String url);
}

View File

@ -1,6 +1,6 @@
package com.magamochi.model.repository;
import com.magamochi.model.entity.Manga;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.model.entity.User;
import com.magamochi.model.entity.UserFavoriteManga;
import java.util.Optional;

View File

@ -1,6 +1,6 @@
package com.magamochi.model.repository;
import com.magamochi.model.entity.Manga;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.model.entity.User;
import com.magamochi.model.entity.UserMangaFollow;
import java.util.List;

View File

@ -2,9 +2,9 @@ package com.magamochi.model.specification;
import static java.util.Objects.nonNull;
import com.magamochi.model.dto.MangaListFilterDTO;
import com.magamochi.model.entity.Author;
import com.magamochi.model.entity.Manga;
import com.magamochi.catalog.model.dto.MangaListFilterDTO;
import com.magamochi.catalog.model.entity.Author;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.model.entity.User;
import jakarta.persistence.criteria.*;
import java.util.ArrayList;

View File

@ -1,21 +0,0 @@
package com.magamochi.queue;
import com.magamochi.model.dto.UpdateMangaDataCommand;
import com.magamochi.service.MangaImportService;
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 UpdateMangaDataConsumer {
private final MangaImportService mangaImportService;
@RabbitListener(queues = "${rabbit-mq.queues.manga-data-update}")
public void receiveUpdateMangaDataCommand(UpdateMangaDataCommand command) {
log.info("Received update manga data command: {}", command);
mangaImportService.updateMangaData(command.mangaId());
}
}

View File

@ -1,23 +0,0 @@
package com.magamochi.queue;
import com.magamochi.model.dto.UpdateMangaDataCommand;
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 UpdateMangaDataProducer {
private final RabbitTemplate rabbitTemplate;
@Value("${rabbit-mq.queues.manga-data-update}")
private String mangaDataUpdateQueue;
public void sendUpdateMangaDataCommand(UpdateMangaDataCommand command) {
rabbitTemplate.convertAndSend(mangaDataUpdateQueue, command);
log.info("Sent update manga data command: {}", command);
}
}

View File

@ -1,7 +1,7 @@
package com.magamochi.queue;
import com.magamochi.model.dto.UpdateMangaFollowChapterListCommand;
import com.magamochi.service.MangaService;
import com.magamochi.service.OldMangaService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
@ -11,11 +11,11 @@ import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UpdateMangaFollowChapterListConsumer {
private final MangaService mangaService;
private final OldMangaService oldMangaService;
@RabbitListener(queues = "${rabbit-mq.queues.manga-follow-update-chapter}")
public void receiveMangaFollowUpdateChapterCommand(UpdateMangaFollowChapterListCommand command) {
log.info("Received update followed manga chapter list command: {}", command);
mangaService.fetchFollowedMangaChapters(command.mangaProviderId());
oldMangaService.fetchFollowedMangaChapters(command.mangaProviderId());
}
}

View File

@ -1,21 +0,0 @@
package com.magamochi.queue;
import com.magamochi.model.dto.MangaListUpdateCommand;
import com.magamochi.service.MangaListService;
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 UpdateMangaListConsumer {
private final MangaListService mangaListService;
@RabbitListener(queues = "${rabbit-mq.queues.manga-list-update}")
public void receiveUpdateMangaListCommand(MangaListUpdateCommand command) {
log.info("Received update manga list command: {}", command);
mangaListService.updateMangaList(command.contentProviderName(), command.page());
}
}

View File

@ -1,23 +0,0 @@
package com.magamochi.queue;
import com.magamochi.model.dto.MangaListUpdateCommand;
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 UpdateMangaListProducer {
private final RabbitTemplate rabbitTemplate;
@Value("${rabbit-mq.queues.manga-list-update}")
private String mangaListUpdateQueue;
public void sendUpdateMangaListCommand(MangaListUpdateCommand command) {
rabbitTemplate.convertAndSend(mangaListUpdateQueue, command);
log.info("Sent update manga list command: {}", command);
}
}

View File

@ -1,163 +0,0 @@
package com.magamochi.service;
import static java.util.Objects.nonNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.client.AniListClient;
import com.magamochi.client.JikanClient;
import com.magamochi.ingestion.model.entity.ContentProvider;
import com.magamochi.model.dto.TitleMatchRequestDTO;
import com.magamochi.model.dto.UpdateMangaDataCommand;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.entity.MangaImportReview;
import com.magamochi.model.repository.MangaImportReviewRepository;
import com.magamochi.model.repository.MangaRepository;
import com.magamochi.queue.UpdateMangaDataProducer;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class MangaCreationService {
private final MangaRepository mangaRepository;
private final MangaImportReviewRepository mangaImportReviewRepository;
private final TitleMatcherService titleMatcherService;
private final JikanClient jikanClient;
private final AniListClient aniListClient;
private final RateLimiter jikanRateLimiter;
private final UpdateMangaDataProducer updateMangaDataProducer;
public Manga getOrCreateManga(String title, String url, ContentProvider contentProvider) {
var existingManga = mangaRepository.findByTitleIgnoreCase(title);
if (existingManga.isPresent()) {
return existingManga.get();
}
jikanRateLimiter.acquire();
var jikanResults = jikanClient.mangaSearch(title).data();
if (jikanResults.isEmpty()) {
createMangaImportReview(title, url, contentProvider);
log.warn("No manga found with title {}", title);
return null;
}
var titleMatchResponse =
titleMatcherService.findBestMatch(
TitleMatchRequestDTO.builder()
.title(title)
.options(
jikanResults.stream()
.flatMap(
results ->
results.titles().stream()
.map(JikanClient.SearchResponse.MangaData.TitleData::title))
.toList())
.build());
if (!titleMatchResponse.isMatchFound()) {
createMangaImportReview(title, url, contentProvider);
log.warn("No match found for manga with title {}", title);
return null;
}
var resultOptional =
jikanResults.stream()
.filter(
results ->
results.titles().stream()
.map(JikanClient.SearchResponse.MangaData.TitleData::title)
.toList()
.contains(titleMatchResponse.getBestMatch()))
.findFirst();
if (resultOptional.isEmpty()) {
createMangaImportReview(title, url, contentProvider);
log.warn("No match found for manga with title {}", title);
return null;
}
var result = resultOptional.get();
return getOrCreateManga(result.mal_id(), null, result.title());
}
public Manga getOrCreateManga(Long malId, Long aniListId) {
if (nonNull(malId)) {
try {
jikanRateLimiter.acquire();
var data = jikanClient.getMangaById(malId);
return getOrCreateManga(data.data().mal_id(), aniListId, data.data().title());
} catch (feign.FeignException.NotFound e) {
log.warn("Manga not found on MyAnimeList for ID: {}", malId);
}
}
if (nonNull(aniListId)) {
try {
var query =
"""
query ($id: Int) {
Media (id: $id, type: MANGA) {
id
idMal
title {
romaji
english
native
}
}
}
""";
var request =
new AniListClient.GraphQLRequest(
query, new AniListClient.GraphQLRequest.Variables(aniListId));
var data = aniListClient.getManga(request).data().Media();
String title =
nonNull(data.title().english()) ? data.title().english() : data.title().romaji();
return getOrCreateManga(data.idMal(), data.id(), title);
} catch (feign.FeignException.NotFound e) {
log.warn("Manga not found on AniList for ID: {}", aniListId);
}
}
throw new RuntimeException("Could not find manga on any provider");
}
private Manga getOrCreateManga(Long malId, Long aniListId, String title) {
var mangaOptional = java.util.Optional.<Manga>empty();
if (nonNull(malId)) {
mangaOptional = mangaRepository.findByMalId(malId);
}
if (mangaOptional.isEmpty() && nonNull(aniListId)) {
mangaOptional = mangaRepository.findByAniListId(aniListId);
}
return mangaOptional.orElseGet(
() -> {
var manga =
mangaRepository.save(
Manga.builder().title(title).malId(malId).aniListId(aniListId).build());
updateMangaDataProducer.sendUpdateMangaDataCommand(
new UpdateMangaDataCommand(manga.getId()));
return manga;
});
}
private void createMangaImportReview(String title, String url, ContentProvider contentProvider) {
if (mangaImportReviewRepository.existsByTitleIgnoreCaseAndUrlIgnoreCase(title, url)) {
return;
}
mangaImportReviewRepository.save(
MangaImportReview.builder().title(title).url(url).contentProvider(contentProvider).build());
}
}

View File

@ -1,86 +0,0 @@
package com.magamochi.service;
import static java.util.Objects.isNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.client.JikanClient;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.model.dto.ImportReviewDTO;
import com.magamochi.model.dto.UpdateMangaDataCommand;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.entity.MangaImportReview;
import com.magamochi.model.repository.MangaContentProviderRepository;
import com.magamochi.model.repository.MangaImportReviewRepository;
import com.magamochi.model.repository.MangaRepository;
import com.magamochi.queue.UpdateMangaDataProducer;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class MangaImportReviewService {
private final MangaImportReviewRepository mangaImportReviewRepository;
private final MangaRepository mangaRepository;
private final MangaContentProviderRepository mangaContentProviderRepository;
private final JikanClient jikanClient;
private final RateLimiter jikanRateLimiter;
private final UpdateMangaDataProducer updateMangaDataProducer;
public List<ImportReviewDTO> getImportReviews() {
return mangaImportReviewRepository.findAll().stream().map(ImportReviewDTO::from).toList();
}
public void deleteImportReview(Long id) {
var importReview = getImportReviewThrowIfNotFound(id);
mangaImportReviewRepository.delete(importReview);
}
public void resolveImportReview(Long id, String malId) {
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.getTitle())
.contentProvider(importReview.getContentProvider())
.url(importReview.getUrl())
.build());
}
mangaImportReviewRepository.delete(importReview);
updateMangaDataProducer.sendUpdateMangaDataCommand(new UpdateMangaDataCommand(manga.getId()));
}
private MangaImportReview getImportReviewThrowIfNotFound(Long id) {
return mangaImportReviewRepository
.findById(id)
.orElseThrow(() -> new NotFoundException("Import review not found for ID: " + id));
}
}

View File

@ -1,434 +1,438 @@
package com.magamochi.service;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import com.google.common.util.concurrent.RateLimiter;
import com.magamochi.catalog.model.entity.Genre;
import com.magamochi.catalog.model.repository.GenreRepository;
import com.magamochi.client.AniListClient;
import com.magamochi.client.JikanClient;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.ingestion.model.entity.ContentProvider;
import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.model.entity.*;
import com.magamochi.model.repository.*;
import com.magamochi.util.DoubleUtil;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.IntStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Log4j2
@Service
@RequiredArgsConstructor
public class MangaImportService {
private final ProviderService providerService;
private final MangaCreationService mangaCreationService;
private final ImageService imageService;
private final LanguageService languageService;
private final GenreRepository genreRepository;
private final MangaGenreRepository mangaGenreRepository;
private final MangaContentProviderRepository mangaContentProviderRepository;
private final AuthorRepository authorRepository;
private final MangaAuthorRepository mangaAuthorRepository;
private final MangaChapterRepository mangaChapterRepository;
private final MangaRepository mangaRepository;
private final JikanClient jikanClient;
private final AniListClient aniListClient;
private final MangaChapterImageRepository mangaChapterImageRepository;
private final MangaAlternativeTitlesRepository mangaAlternativeTitlesRepository;
private final RateLimiter jikanRateLimiter;
public void importMangaFiles(String malId, List<MultipartFile> files) {
log.info("Importing manga files for MAL ID {}", malId);
var provider = providerService.getOrCreateProvider("Manual Import", false);
jikanRateLimiter.acquire();
var mangaData = jikanClient.getMangaById(Long.parseLong(malId));
var mangaProvider = getOrCreateMangaProvider(mangaData.data().title(), provider);
var sortedFiles = files.stream().sorted(Comparator.comparing(MultipartFile::getName)).toList();
IntStream.rangeClosed(1, sortedFiles.size())
.forEach(
fileIndex -> {
var file = sortedFiles.get(fileIndex - 1);
log.info(
"Importing file {}/{}: {}, for Mangá {}",
fileIndex,
sortedFiles.size(),
file.getOriginalFilename(),
mangaProvider.getManga().getTitle());
var chapter =
persistMangaChapter(
mangaProvider,
new ContentProviderMangaChapterResponseDTO(
removeFileExtension(file.getOriginalFilename()),
"manual_" + file.getOriginalFilename(),
file.getOriginalFilename(),
"en-US"));
List<MangaChapterImage> allChapterImages = new ArrayList<>();
try (InputStream is = file.getInputStream();
ZipInputStream zis = new ZipInputStream(is)) {
ZipEntry entry;
var position = 0;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) {
continue;
}
var os = new ByteArrayOutputStream();
zis.transferTo(os);
var bytes = os.toByteArray();
var image =
imageService.uploadImage(bytes, "image/jpeg", "chapter/" + chapter.getId());
var chapterImage =
MangaChapterImage.builder()
.position(position++)
.image(image)
.mangaChapter(chapter)
.build();
allChapterImages.add(chapterImage);
zis.closeEntry();
}
log.info("Chapter images added for chapter {}", chapter.getTitle());
} catch (IOException e) {
throw new RuntimeException(e);
}
mangaChapterImageRepository.saveAll(allChapterImages);
chapter.setDownloaded(true);
mangaChapterRepository.save(chapter);
});
log.info("Import manga files for MAL ID {} completed.", malId);
}
public void updateMangaData(Long mangaId) {
var manga =
mangaRepository
.findById(mangaId)
.orElseThrow(() -> new NotFoundException("Manga not found for ID: " + mangaId));
updateMangaData(manga);
}
public void updateMangaData(Manga manga) {
log.info("Updating manga {}", manga.getTitle());
if (nonNull(manga.getMalId())) {
try {
updateFromJikan(manga);
return;
} catch (Exception e) {
log.warn(
"Error updating manga data from Jikan for manga {}. Trying AniList... Error: {}",
manga.getTitle(),
e.getMessage());
}
}
if (nonNull(manga.getAniListId())) {
try {
updateFromAniList(manga);
return;
} catch (Exception e) {
log.warn(
"Error updating manga data from AniList for manga {}. Error: {}",
manga.getTitle(),
e.getMessage());
}
}
log.warn(
"Could not update manga data for {}. No provider data available/found.", manga.getTitle());
}
private void updateFromJikan(Manga manga) throws IOException, URISyntaxException {
jikanRateLimiter.acquire();
var mangaData = jikanClient.getMangaById(manga.getMalId());
manga.setSynopsis(mangaData.data().synopsis());
manga.setStatus(mangaData.data().status());
manga.setScore(DoubleUtil.round((double) mangaData.data().score(), 2));
manga.setPublishedFrom(mangaData.data().published().from());
manga.setPublishedTo(mangaData.data().published().to());
manga.setChapterCount(mangaData.data().chapters());
var authors =
mangaData.data().authors().stream()
.map(
authorData ->
authorRepository
.findByMalId(authorData.mal_id())
.orElseGet(
() ->
authorRepository.save(
Author.builder()
.malId(authorData.mal_id())
.name(authorData.name())
.build())))
.toList();
updateMangaAuthors(manga, authors);
var genres =
mangaData.data().genres().stream()
.map(
genreData ->
genreRepository
.findByMalId(genreData.mal_id())
.orElseGet(
() ->
genreRepository.save(
Genre.builder()
.malId(genreData.mal_id())
.name(genreData.name())
.build())))
.toList();
updateMangaGenres(manga, genres);
if (isNull(manga.getCoverImage())) {
downloadCoverImage(manga, mangaData.data().images().jpg().large_image_url());
}
var mangaEntity = mangaRepository.save(manga);
var alternativeTitles =
mangaData.data().title_synonyms().stream()
.map(at -> MangaAlternativeTitle.builder().manga(mangaEntity).title(at).build())
.toList();
mangaAlternativeTitlesRepository.saveAll(alternativeTitles);
}
private void updateFromAniList(Manga manga) throws IOException, URISyntaxException {
var query =
"""
query ($id: Int) {
Media (id: $id, type: MANGA) {
startDate { year month day }
endDate { year month day }
description
status
averageScore
chapters
coverImage { large }
genres
staff {
edges {
role
node {
name {
full
}
}
}
}
}
}
""";
var request =
new AniListClient.GraphQLRequest(
query, new AniListClient.GraphQLRequest.Variables(manga.getAniListId()));
var media = aniListClient.getManga(request).data().Media();
manga.setSynopsis(media.description());
manga.setStatus(mapAniListStatus(media.status()));
manga.setScore(DoubleUtil.round((double) media.averageScore() / 10, 2)); // 0-100 -> 0-10
manga.setPublishedFrom(convertFuzzyDate(media.startDate()));
manga.setPublishedTo(convertFuzzyDate(media.endDate()));
manga.setChapterCount(media.chapters());
var authors =
media.staff().edges().stream()
.filter(edge -> isAuthorRole(edge.role()))
.map(edge -> edge.node().name().full())
.distinct()
.map(
name ->
authorRepository
.findByName(name)
.orElseGet(
() -> authorRepository.save(Author.builder().name(name).build())))
.toList();
updateMangaAuthors(manga, authors);
var genres =
media.genres().stream()
.map(
name ->
genreRepository
.findByName(name)
.orElseGet(() -> genreRepository.save(Genre.builder().name(name).build())))
.toList();
updateMangaGenres(manga, genres);
if (isNull(manga.getCoverImage())) {
downloadCoverImage(manga, media.coverImage().large());
}
mangaRepository.save(manga);
}
private boolean isAuthorRole(String role) {
return role.equalsIgnoreCase("Story & Art")
|| role.equalsIgnoreCase("Story")
|| role.equalsIgnoreCase("Art");
}
private String mapAniListStatus(String status) {
return switch (status) {
case "RELEASING" -> "Publishing";
case "FINISHED" -> "Finished";
case "NOT_YET_RELEASED" -> "Not yet published";
default -> "Unknown";
};
}
private OffsetDateTime convertFuzzyDate(AniListClient.MangaResponse.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 void updateMangaAuthors(Manga manga, List<Author> authors) {
var mangaAuthors =
authors.stream()
.map(
author ->
mangaAuthorRepository
.findByMangaAndAuthor(manga, author)
.orElseGet(
() ->
mangaAuthorRepository.save(
MangaAuthor.builder().manga(manga).author(author).build())))
.toList();
manga.setMangaAuthors(mangaAuthors);
}
private void updateMangaGenres(Manga manga, List<Genre> genres) {
var mangaGenres =
genres.stream()
.map(
genre ->
mangaGenreRepository
.findByMangaAndGenre(manga, genre)
.orElseGet(
() ->
mangaGenreRepository.save(
MangaGenre.builder().manga(manga).genre(genre).build())))
.toList();
manga.setMangaGenres(mangaGenres);
}
private void downloadCoverImage(Manga manga, String imageUrl)
throws IOException, URISyntaxException {
var inputStream =
new BufferedInputStream(new URL(new URI(imageUrl).toASCIIString()).openStream());
var bytes = inputStream.readAllBytes();
inputStream.close();
var image = imageService.uploadImage(bytes, "image/jpeg", "cover");
manga.setCoverImage(image);
}
public MangaChapter persistMangaChapter(
MangaContentProvider mangaContentProvider, ContentProviderMangaChapterResponseDTO chapter) {
var mangaChapter =
mangaChapterRepository
.findByMangaContentProviderAndUrlIgnoreCase(mangaContentProvider, chapter.chapterUrl())
.orElseGet(MangaChapter::new);
mangaChapter.setMangaContentProvider(mangaContentProvider);
mangaChapter.setTitle(chapter.chapterTitle());
mangaChapter.setUrl(chapter.chapterUrl());
var language = languageService.getOrThrow(chapter.languageCode());
mangaChapter.setLanguage(language);
if (nonNull(chapter.chapter())) {
try {
mangaChapter.setChapterNumber(Integer.parseInt(chapter.chapter()));
} catch (NumberFormatException e) {
log.warn(
"Could not parse chapter number {} from manga {}",
chapter.chapter(),
mangaContentProvider.getManga().getTitle());
}
}
return mangaChapterRepository.save(mangaChapter);
}
private MangaContentProvider getOrCreateMangaProvider(
String title, ContentProvider contentProvider) {
return mangaContentProviderRepository
.findByMangaTitleIgnoreCaseAndContentProvider(title, contentProvider)
.orElseGet(
() -> {
jikanRateLimiter.acquire();
var manga = mangaCreationService.getOrCreateManga(title, "manual", contentProvider);
return mangaContentProviderRepository.save(
MangaContentProvider.builder()
.manga(manga)
.mangaTitle(manga.getTitle())
.contentProvider(contentProvider)
.url("manual")
.build());
});
}
private String removeFileExtension(String filename) {
if (StringUtils.isBlank(filename)) {
return filename;
}
int lastDotIndex = filename.lastIndexOf('.');
// No dot, or dot is the first character (like .gitignore)
if (lastDotIndex <= 0) {
return filename;
}
return filename.substring(0, lastDotIndex);
}
}
// package com.magamochi.service;
//
// import static java.util.Objects.isNull;
// import static java.util.Objects.nonNull;
//
// import com.google.common.util.concurrent.RateLimiter;
// import com.magamochi.catalog.model.entity.Genre;
// import com.magamochi.catalog.model.repository.GenreRepository;
// import com.magamochi.catalog.client.AniListClient;
// import com.magamochi.catalog.client.JikanClient;
// import com.magamochi.common.exception.NotFoundException;
// import com.magamochi.ingestion.model.entity.ContentProvider;
// import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO;
// import com.magamochi.model.entity.*;
// import com.magamochi.model.repository.*;
// import com.magamochi.catalog.util.DoubleUtil;
// import java.io.*;
// import java.net.URI;
// import java.net.URISyntaxException;
// import java.net.URL;
// import java.time.OffsetDateTime;
// import java.time.ZoneOffset;
// import java.util.ArrayList;
// import java.util.Comparator;
// import java.util.List;
// import java.util.stream.IntStream;
// import java.util.zip.ZipEntry;
// import java.util.zip.ZipInputStream;
// import lombok.RequiredArgsConstructor;
// import lombok.extern.log4j.Log4j2;
// import org.apache.commons.lang3.StringUtils;
// import org.springframework.stereotype.Service;
// import org.springframework.web.multipart.MultipartFile;
//
// @Log4j2
//// @Service
// @RequiredArgsConstructor
// public class MangaImportService {
// private final ProviderService providerService;
// private final MangaCreationService mangaCreationService;
// private final ImageService imageService;
// private final LanguageService languageService;
//
// private final GenreRepository genreRepository;
// private final MangaGenreRepository mangaGenreRepository;
// private final MangaContentProviderRepository mangaContentProviderRepository;
// private final AuthorRepository authorRepository;
// private final MangaAuthorRepository mangaAuthorRepository;
// private final MangaChapterRepository mangaChapterRepository;
// private final MangaRepository mangaRepository;
//
// private final JikanClient jikanClient;
// private final AniListClient aniListClient;
// private final MangaChapterImageRepository mangaChapterImageRepository;
// private final MangaAlternativeTitlesRepository mangaAlternativeTitlesRepository;
//
// private final RateLimiter jikanRateLimiter;
//
// public void importMangaFiles(String malId, List<MultipartFile> files) {
// log.info("Importing manga files for MAL ID {}", malId);
// var provider = providerService.getOrCreateProvider("Manual Import", false);
//
// jikanRateLimiter.acquire();
// var mangaData = jikanClient.getMangaById(Long.parseLong(malId));
//
// var mangaProvider = getOrCreateMangaProvider(mangaData.data().title(), provider);
//
// var sortedFiles =
// files.stream().sorted(Comparator.comparing(MultipartFile::getName)).toList();
//
// IntStream.rangeClosed(1, sortedFiles.size())
// .forEach(
// fileIndex -> {
// var file = sortedFiles.get(fileIndex - 1);
// log.info(
// "Importing file {}/{}: {}, for Mangá {}",
// fileIndex,
// sortedFiles.size(),
// file.getOriginalFilename(),
// mangaProvider.getManga().getTitle());
//
// var chapter =
// persistMangaChapter(
// mangaProvider,
// new ContentProviderMangaChapterResponseDTO(
// removeFileExtension(file.getOriginalFilename()),
// "manual_" + file.getOriginalFilename(),
// file.getOriginalFilename(),
// "en-US"));
//
// List<MangaChapterImage> allChapterImages = new ArrayList<>();
// try (InputStream is = file.getInputStream();
// ZipInputStream zis = new ZipInputStream(is)) {
// ZipEntry entry;
// var position = 0;
//
// while ((entry = zis.getNextEntry()) != null) {
// if (entry.isDirectory()) {
// continue;
// }
//
// var os = new ByteArrayOutputStream();
// zis.transferTo(os);
// var bytes = os.toByteArray();
//
// var image =
// imageService.uploadImage(bytes, "image/jpeg", "chapter/" + chapter.getId());
//
// var chapterImage =
// MangaChapterImage.builder()
// .position(position++)
// .image(image)
// .mangaChapter(chapter)
// .build();
//
// allChapterImages.add(chapterImage);
// zis.closeEntry();
// }
//
// log.info("Chapter images added for chapter {}", chapter.getTitle());
// } catch (IOException e) {
// throw new RuntimeException(e);
// }
//
// mangaChapterImageRepository.saveAll(allChapterImages);
// chapter.setDownloaded(true);
// mangaChapterRepository.save(chapter);
// });
//
// log.info("Import manga files for MAL ID {} completed.", malId);
// }
//
// public void updateMangaData(Long mangaId) {
// var manga =
// mangaRepository
// .findById(mangaId)
// .orElseThrow(() -> new NotFoundException("Manga not found for ID: " + mangaId));
//
// updateMangaData(manga);
// }
//
// public void updateMangaData(Manga manga) {
// log.info("Updating manga {}", manga.getTitle());
//
// if (nonNull(manga.getMalId())) {
// try {
// updateFromJikan(manga);
// return;
// } catch (Exception e) {
// log.warn(
// "Error updating manga data from Jikan for manga {}. Trying AniList... Error: {}",
// manga.getTitle(),
// e.getMessage());
// }
// }
//
// if (nonNull(manga.getAniListId())) {
// try {
// updateFromAniList(manga);
// return;
// } catch (Exception e) {
// log.warn(
// "Error updating manga data from AniList for manga {}. Error: {}",
// manga.getTitle(),
// e.getMessage());
// }
// }
//
// log.warn(
// "Could not update manga data for {}. No provider data available/found.",
// manga.getTitle());
// }
//
// private void updateFromJikan(Manga manga) throws IOException, URISyntaxException {
// jikanRateLimiter.acquire();
// var mangaData = jikanClient.getMangaById(manga.getMalId());
//
// manga.setSynopsis(mangaData.data().synopsis());
// manga.setStatus(mangaData.data().status());
// manga.setScore(DoubleUtil.round((double) mangaData.data().score(), 2));
// manga.setPublishedFrom(mangaData.data().published().from());
// manga.setPublishedTo(mangaData.data().published().to());
// manga.setChapterCount(mangaData.data().chapters());
//
// var authors =
// mangaData.data().authors().stream()
// .map(
// authorData ->
// authorRepository
// .findByMalId(authorData.mal_id())
// .orElseGet(
// () ->
// authorRepository.save(
// Author.builder()
// .malId(authorData.mal_id())
// .name(authorData.name())
// .build())))
// .toList();
//
// updateMangaAuthors(manga, authors);
//
// var genres =
// mangaData.data().genres().stream()
// .map(
// genreData ->
// genreRepository
// .findByMalId(genreData.mal_id())
// .orElseGet(
// () ->
// genreRepository.save(
// Genre.builder()
// .malId(genreData.mal_id())
// .name(genreData.name())
// .build())))
// .toList();
//
// updateMangaGenres(manga, genres);
//
// if (isNull(manga.getCoverImage())) {
// downloadCoverImage(manga, mangaData.data().images().jpg().large_image_url());
// }
//
// var mangaEntity = mangaRepository.save(manga);
// var alternativeTitles =
// mangaData.data().title_synonyms().stream()
// .map(at -> MangaAlternativeTitle.builder().manga(mangaEntity).title(at).build())
// .toList();
// mangaAlternativeTitlesRepository.saveAll(alternativeTitles);
// }
//
// private void updateFromAniList(Manga manga) throws IOException, URISyntaxException {
// var query =
// """
// query ($id: Int) {
// Media (id: $id, type: MANGA) {
// startDate { year month day }
// endDate { year month day }
// description
// status
// averageScore
// chapters
// coverImage { large }
// genres
// staff {
// edges {
// role
// node {
// name {
// full
// }
// }
// }
// }
// }
// }
// """;
// var request =
// new AniListClient.GraphQLRequest(
// query, new AniListClient.GraphQLRequest.Variables(manga.getAniListId()));
// var media = aniListClient.getManga(request).data().Media();
//
// manga.setSynopsis(media.description());
// manga.setStatus(mapAniListStatus(media.status()));
// manga.setScore(DoubleUtil.round((double) media.averageScore() / 10, 2)); // 0-100 -> 0-10
// manga.setPublishedFrom(convertFuzzyDate(media.startDate()));
// manga.setPublishedTo(convertFuzzyDate(media.endDate()));
// manga.setChapterCount(media.chapters());
//
// var authors =
// media.staff().edges().stream()
// .filter(edge -> isAuthorRole(edge.role()))
// .map(edge -> edge.node().name().full())
// .distinct()
// .map(
// name ->
// authorRepository
// .findByName(name)
// .orElseGet(
// () -> authorRepository.save(Author.builder().name(name).build())))
// .toList();
//
// updateMangaAuthors(manga, authors);
//
// var genres =
// media.genres().stream()
// .map(
// name ->
// genreRepository
// .findByName(name)
// .orElseGet(() ->
// genreRepository.save(Genre.builder().name(name).build())))
// .toList();
//
// updateMangaGenres(manga, genres);
//
// if (isNull(manga.getCoverImage())) {
// downloadCoverImage(manga, media.coverImage().large());
// }
//
// mangaRepository.save(manga);
// }
//
// private boolean isAuthorRole(String role) {
// return role.equalsIgnoreCase("Story & Art")
// || role.equalsIgnoreCase("Story")
// || role.equalsIgnoreCase("Art");
// }
//
// private String mapAniListStatus(String status) {
// return switch (status) {
// case "RELEASING" -> "Publishing";
// case "FINISHED" -> "Finished";
// case "NOT_YET_RELEASED" -> "Not yet published";
// default -> "Unknown";
// };
// }
//
// private OffsetDateTime convertFuzzyDate(AniListClient.MangaResponse.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 void updateMangaAuthors(Manga manga, List<Author> authors) {
// var mangaAuthors =
// authors.stream()
// .map(
// author ->
// mangaAuthorRepository
// .findByMangaAndAuthor(manga, author)
// .orElseGet(
// () ->
// mangaAuthorRepository.save(
// MangaAuthor.builder().manga(manga).author(author).build())))
// .toList();
// manga.setMangaAuthors(mangaAuthors);
// }
//
// private void updateMangaGenres(Manga manga, List<Genre> genres) {
// var mangaGenres =
// genres.stream()
// .map(
// genre ->
// mangaGenreRepository
// .findByMangaAndGenre(manga, genre)
// .orElseGet(
// () ->
// mangaGenreRepository.save(
// MangaGenre.builder().manga(manga).genre(genre).build())))
// .toList();
// manga.setMangaGenres(mangaGenres);
// }
//
// private void downloadCoverImage(Manga manga, String imageUrl)
// throws IOException, URISyntaxException {
// var inputStream =
// new BufferedInputStream(new URL(new URI(imageUrl).toASCIIString()).openStream());
//
// var bytes = inputStream.readAllBytes();
//
// inputStream.close();
// var image = imageService.uploadImage(bytes, "image/jpeg", "cover");
//
// manga.setCoverImage(image);
// }
//
// public MangaChapter persistMangaChapter(
// MangaContentProvider mangaContentProvider, ContentProviderMangaChapterResponseDTO chapter) {
// var mangaChapter =
// mangaChapterRepository
// .findByMangaContentProviderAndUrlIgnoreCase(mangaContentProvider,
// chapter.chapterUrl())
// .orElseGet(MangaChapter::new);
//
// mangaChapter.setMangaContentProvider(mangaContentProvider);
// mangaChapter.setTitle(chapter.chapterTitle());
// mangaChapter.setUrl(chapter.chapterUrl());
//
// var language = languageService.getOrThrow(chapter.languageCode());
// mangaChapter.setLanguage(language);
//
// if (nonNull(chapter.chapter())) {
// try {
// mangaChapter.setChapterNumber(Integer.parseInt(chapter.chapter()));
// } catch (NumberFormatException e) {
// log.warn(
// "Could not parse chapter number {} from manga {}",
// chapter.chapter(),
// mangaContentProvider.getManga().getTitle());
// }
// }
//
// return mangaChapterRepository.save(mangaChapter);
// }
//
// private MangaContentProvider getOrCreateMangaProvider(
// String title, ContentProvider contentProvider) {
// return mangaContentProviderRepository
// .findByMangaTitleIgnoreCaseAndContentProvider(title, contentProvider)
// .orElseGet(
// () -> {
// jikanRateLimiter.acquire();
// var manga = mangaCreationService.getOrCreateManga(title, "manual", contentProvider);
//
// return mangaContentProviderRepository.save(
// MangaContentProvider.builder()
// .manga(manga)
// .mangaTitle(manga.getTitle())
// .contentProvider(contentProvider)
// .url("manual")
// .build());
// });
// }
//
// private String removeFileExtension(String filename) {
// if (StringUtils.isBlank(filename)) {
// return filename;
// }
//
// int lastDotIndex = filename.lastIndexOf('.');
//
// // No dot, or dot is the first character (like .gitignore)
// if (lastDotIndex <= 0) {
// return filename;
// }
//
// return filename.substring(0, lastDotIndex);
// }
// }

View File

@ -1,58 +0,0 @@
package com.magamochi.service;
import static java.util.Objects.isNull;
import com.magamochi.ingestion.providers.PagedContentProviderFactory;
import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.repository.MangaContentProviderRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class MangaListService {
private final ProviderService providerService;
private final MangaCreationService mangaCreationService;
private final PagedContentProviderFactory pagedContentProviderFactory;
private final MangaContentProviderRepository mangaContentProviderRepository;
public void updateMangaList(String contentProviderName, Integer page) {
var contentProvider = pagedContentProviderFactory.getPagedContentProvider(contentProviderName);
var provider = providerService.getOrCreateProvider(contentProviderName);
var mangas = contentProvider.getMangasFromPage(page);
mangas.forEach(
mangaResponse -> {
var mangaProvider =
mangaContentProviderRepository.findByMangaTitleIgnoreCaseAndContentProvider(
mangaResponse.title(), provider);
if (mangaProvider.isPresent()) {
return;
}
var manga =
mangaCreationService.getOrCreateManga(
mangaResponse.title(), mangaResponse.url(), provider);
if (isNull(manga)) {
return;
}
if (!mangaContentProviderRepository.existsByMangaAndContentProviderAndUrlIgnoreCase(
manga, provider, mangaResponse.url())) {
mangaContentProviderRepository.save(
MangaContentProvider.builder()
.manga(manga)
.mangaTitle(mangaResponse.title())
.contentProvider(provider)
.url(mangaResponse.url())
.build());
}
});
}
}

View File

@ -1,40 +1,35 @@
package com.magamochi.service;
import static java.util.Objects.nonNull;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.repository.MangaContentProviderRepository;
import com.magamochi.catalog.model.repository.MangaRepository;
import com.magamochi.client.NtfyClient;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.ingestion.providers.ContentProviderFactory;
import com.magamochi.model.dto.*;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.entity.MangaChapter;
import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.entity.UserMangaFollow;
import com.magamochi.model.repository.*;
import com.magamochi.model.specification.MangaSpecification;
import com.magamochi.queue.MangaChapterDownloadProducer;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Log4j2
@Service
@RequiredArgsConstructor
public class MangaService {
private final MangaImportService mangaImportService;
public class OldMangaService {
// private final MangaImportService mangaImportService;
private final UserService userService;
private final MangaRepository mangaRepository;
private final MangaContentProviderRepository mangaContentProviderRepository;
private final ContentProviderFactory contentProviderFactory;
private final UserFavoriteMangaRepository userFavoriteMangaRepository;
private final UserMangaFollowRepository userMangaFollowRepository;
@ -62,27 +57,6 @@ public class MangaService {
new MangaChapterDownloadCommand(chapterId)));
}
public Page<MangaListDTO> getMangas(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 List<MangaChapterDTO> getMangaChapters(Long mangaProviderId) {
var mangaProvider = getMangaProviderThrowIfNotFound(mangaProviderId);
@ -92,30 +66,6 @@ public class MangaService {
.toList();
}
public MangaDTO getManga(Long mangaId) {
var manga = findMangaByIdThrowIfNotFound(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()));
}
public void fetchFollowedMangaChapters(Long mangaProviderId) {
var mangaProvider =
mangaContentProviderRepository
@ -155,8 +105,8 @@ public class MangaService {
contentProviderFactory.getContentProvider(mangaProvider.getContentProvider().getName());
var availableChapters = contentProvider.getAvailableChapters(mangaProvider);
availableChapters.forEach(
chapter -> mangaImportService.persistMangaChapter(mangaProvider, chapter));
// availableChapters.forEach(
// chapter -> mangaImportService.persistMangaChapter(mangaProvider, chapter));
}
public Manga findMangaByIdThrowIfNotFound(Long mangaId) {

View File

@ -1,25 +1,21 @@
package com.magamochi.service;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import com.magamochi.catalog.model.repository.MangaContentProviderRepository;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.ingestion.model.entity.ContentProvider;
import com.magamochi.ingestion.model.repository.ContentProviderRepository;
import com.magamochi.ingestion.providers.ManualImportContentProviderFactory;
import com.magamochi.model.dto.ImportMangaResponseDTO;
import com.magamochi.model.dto.ImportRequestDTO;
import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.repository.MangaContentProviderRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.NotImplementedException;
import org.springframework.stereotype.Service;
@Log4j2
@Service
@RequiredArgsConstructor
public class ProviderManualMangaImportService {
private final MangaCreationService mangaCreationService;
private final ManualImportContentProviderFactory contentProviderFactory;
@ -27,35 +23,40 @@ public class ProviderManualMangaImportService {
private final MangaContentProviderRepository mangaContentProviderRepository;
public ImportMangaResponseDTO importFromProvider(Long providerId, ImportRequestDTO requestDTO) {
var provider = getProvider(providerId);
var contentProvider = contentProviderFactory.getManualImportContentProvider(provider.getName());
var title = contentProvider.getMangaTitle(requestDTO.id());
var malId = nonNull(requestDTO.metadataId()) ? Long.parseLong(requestDTO.metadataId()) : null;
var aniListId = nonNull(requestDTO.aniListId()) ? Long.parseLong(requestDTO.aniListId()) : null;
var manga =
nonNull(malId) || nonNull(aniListId)
? mangaCreationService.getOrCreateManga(malId, aniListId)
: mangaCreationService.getOrCreateManga(title, requestDTO.id(), provider);
if (isNull(manga)) {
throw new NotFoundException("Manga could not be found or created for ID: " + requestDTO.id());
}
if (!mangaContentProviderRepository.existsByMangaAndContentProviderAndUrlIgnoreCase(
manga, provider, requestDTO.id())) {
mangaContentProviderRepository.save(
MangaContentProvider.builder()
.manga(manga)
.mangaTitle(title)
.contentProvider(provider)
.url(requestDTO.id())
.build());
}
return new ImportMangaResponseDTO(manga.getId());
throw new NotImplementedException();
// var provider = getProvider(providerId);
// var contentProvider =
// contentProviderFactory.getManualImportContentProvider(provider.getName());
//
// var title = contentProvider.getMangaTitle(requestDTO.id());
//
// var malId = nonNull(requestDTO.metadataId()) ? Long.parseLong(requestDTO.metadataId()) :
// null;
// var aniListId = nonNull(requestDTO.aniListId()) ? Long.parseLong(requestDTO.aniListId()) :
// null;
//
// var manga =
// nonNull(malId) || nonNull(aniListId)
// ? mangaCreationService.getOrCreateManga(malId, aniListId)
// : mangaCreationService.getOrCreateManga(title, requestDTO.id(), provider);
//
// if (isNull(manga)) {
// throw new NotFoundException("Manga could not be found or created for ID: " +
// requestDTO.id());
// }
//
// if (!mangaContentProviderRepository.existsByMangaAndContentProviderAndUrlIgnoreCase(
// manga, provider, requestDTO.id())) {
// mangaContentProviderRepository.save(
// MangaContentProvider.builder()
// .manga(manga)
// .mangaTitle(title)
// .contentProvider(provider)
// .url(requestDTO.id())
// .build());
// }
//
// return new ImportMangaResponseDTO(manga.getId());
}
public ContentProvider getProvider(Long providerId) {

View File

@ -1,9 +1,9 @@
package com.magamochi.service;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.repository.MangaRepository;
import com.magamochi.common.exception.NotFoundException;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.entity.UserFavoriteManga;
import com.magamochi.model.repository.MangaRepository;
import com.magamochi.model.repository.UserFavoriteMangaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

View File

@ -1,8 +1,8 @@
package com.magamochi.task;
import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.catalog.model.repository.MangaRepository;
import com.magamochi.model.dto.UpdateMangaFollowChapterListCommand;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.repository.MangaRepository;
import com.magamochi.queue.UpdateMangaFollowChapterListProducer;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

View File

@ -91,6 +91,8 @@ resilience4j:
queues:
manga-ingest: ${MANGA_INGEST_QUEUE:mangaIngest}
provider-page-ingest: ${PROVIDER_PAGE_INGEST_QUEUE:providerPageIngest}
manga-update: ${MANGA_UPDATE_QUEUE:mangaUpdate}
image-fetch: ${IMAGE_FETCH_QUEUE:imageFetch}
rabbit-mq:
queues:

View File

@ -34,6 +34,7 @@ CREATE TABLE mangas
published_to TIMESTAMPTZ,
chapter_count INT DEFAULT 0,
follow BOOLEAN NOT NULL DEFAULT FALSE,
state VARCHAR NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@ -70,6 +71,8 @@ CREATE TABLE manga_chapters
url VARCHAR NOT NULL,
chapter_number INTEGER,
language_id BIGINT REFERENCES languages (id),
downloaded BOOLEAN DEFAULT FALSE,
read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@ -80,17 +83,15 @@ CREATE TABLE manga_chapter_images
manga_chapter_id BIGINT NOT NULL REFERENCES manga_chapters (id) ON DELETE CASCADE,
image_id UUID REFERENCES images (id) ON DELETE CASCADE,
position INT NOT NULL,
downloaded BOOLEAN DEFAULT FALSE,
read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE manga_import_reviews
CREATE TABLE manga_ingest_reviews
(
id BIGSERIAL NOT NULL PRIMARY KEY,
content_provider_id BIGINT NOT NULL REFERENCES content_providers (id) ON DELETE CASCADE,
title VARCHAR NOT NULL,
manga_title VARCHAR NOT NULL,
url VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@ -98,7 +99,6 @@ CREATE TABLE manga_import_reviews
CREATE TABLE authors
(
id BIGSERIAL NOT NULL PRIMARY KEY,
mal_id BIGINT UNIQUE,
name VARCHAR,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
@ -115,7 +115,6 @@ CREATE TABLE manga_author
CREATE TABLE genres
(
id BIGSERIAL NOT NULL PRIMARY KEY,
mal_id BIGINT UNIQUE,
name VARCHAR
);