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> <artifactId>lombok</artifactId>
</path> </path>
</annotationProcessorPaths> </annotationProcessorPaths>
<source>22</source>
<target>22</target>
</configuration> </configuration>
</plugin> </plugin>
<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.time.OffsetDateTime;
import java.util.List; import java.util.List;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
@ -9,7 +8,6 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "jikan", url = "https://api.jikan.moe/v4/manga") @FeignClient(name = "jikan", url = "https://api.jikan.moe/v4/manga")
@Retry(name = "JikanRetry")
public interface JikanClient { public interface JikanClient {
@GetMapping @GetMapping
SearchResponse mangaSearch(@RequestParam String q); SearchResponse mangaSearch(@RequestParam String q);
@ -30,10 +28,10 @@ public interface JikanClient {
String title, String title,
List<String> title_synonyms, List<String> title_synonyms,
String status, String status,
boolean publishing, Boolean publishing,
String synopsis, String synopsis,
float score, Float score,
int chapters, Integer chapters,
PublishData published, PublishData published,
List<AuthorData> authors, List<AuthorData> authors,
List<GenreData> genres) { List<GenreData> genres) {

View File

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

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.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.time.Instant; import java.time.Instant;
public record ImportReviewDTO( public record MangaIngestReviewDTO(
@NotNull Long id, @NotNull Long id,
@NotBlank String title, @NotBlank String title,
@NotBlank String providerName, @NotBlank String contentProviderName,
String externalUrl, String externalUrl,
@NotBlank String reason, @NotBlank String reason,
@NotNull Instant createdAt) { @NotNull Instant createdAt) {
public static ImportReviewDTO from(MangaImportReview review) { public static MangaIngestReviewDTO from(MangaIngestReview review) {
return new ImportReviewDTO( return new MangaIngestReviewDTO(
review.getId(), review.getId(),
review.getTitle(), review.getMangaTitle(),
review.getContentProvider().getName(), review.getContentProvider().getName(),
review.getUrl(), review.getUrl(),
"Title match not found", "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 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.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@ -12,7 +13,7 @@ public record MangaListDTO(
@NotNull Long id, @NotNull Long id,
@NotBlank String title, @NotBlank String title,
String coverImageKey, String coverImageKey,
String status, MangaStatus status,
OffsetDateTime publishedFrom, OffsetDateTime publishedFrom,
OffsetDateTime publishedTo, OffsetDateTime publishedTo,
Integer providerCount, Integer providerCount,

View File

@ -1,4 +1,4 @@
package com.magamochi.model.dto; package com.magamochi.catalog.model.dto;
import java.util.List; 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 jakarta.persistence.*;
import java.time.Instant; import java.time.Instant;
@ -19,8 +19,6 @@ public class Author {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
private Long malId;
private String name; private String name;
@CreationTimestamp private Instant createdAt; @CreationTimestamp private Instant createdAt;

View File

@ -1,6 +1,5 @@
package com.magamochi.catalog.model.entity; package com.magamochi.catalog.model.entity;
import com.magamochi.model.entity.MangaGenre;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.util.List; import java.util.List;
import lombok.*; import lombok.*;
@ -17,8 +16,6 @@ public class Genre {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
private Long malId;
private String name; private String name;
@OneToMany(mappedBy = "genre") @OneToMany(mappedBy = "genre")

View File

@ -1,4 +1,4 @@
package com.magamochi.model.entity; package com.magamochi.catalog.model.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; 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 jakarta.persistence.*;
import java.time.Instant; import java.time.Instant;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
@ -26,14 +31,11 @@ public class Manga {
private String title; private String title;
private String status; @Enumerated(EnumType.STRING)
private MangaStatus status;
private String synopsis; private String synopsis;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
@OneToMany(mappedBy = "manga") @OneToMany(mappedBy = "manga")
private List<MangaContentProvider> mangaContentProviders; private List<MangaContentProvider> mangaContentProviders;
@ -47,6 +49,18 @@ public class Manga {
private OffsetDateTime publishedTo; private OffsetDateTime publishedTo;
@Builder.Default private Integer chapterCount = 0;
@Builder.Default private Boolean follow = false;
@Enumerated(EnumType.STRING)
@Builder.Default
private MangaState state = MangaState.PENDING;
@CreationTimestamp private Instant createdAt;
@UpdateTimestamp private Instant updatedAt;
@OneToMany(mappedBy = "manga") @OneToMany(mappedBy = "manga")
private List<MangaAuthor> mangaAuthors; private List<MangaAuthor> mangaAuthors;
@ -58,8 +72,4 @@ public class Manga {
@OneToMany(mappedBy = "manga") @OneToMany(mappedBy = "manga")
private List<MangaAlternativeTitle> alternativeTitles; private List<MangaAlternativeTitle> alternativeTitles;
@Builder.Default private Integer chapterCount = 0;
@Builder.Default private Boolean follow = false;
} }

View File

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

View File

@ -1,4 +1,4 @@
package com.magamochi.model.entity; package com.magamochi.catalog.model.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; 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 jakarta.persistence.*;
import lombok.*; 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 com.magamochi.ingestion.model.entity.ContentProvider;
import jakarta.persistence.*; import jakarta.persistence.*;
@ -7,18 +7,18 @@ import lombok.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
@Entity @Entity
@Table(name = "manga_import_reviews") @Table(name = "manga_ingest_reviews")
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Getter @Getter
@Setter @Setter
public class MangaImportReview { public class MangaIngestReview {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
private String title; private String mangaTitle;
private String url; private String url;

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 { public enum MangaStatus {
ONGOING, 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; package com.magamochi.catalog.model.repository;
import com.magamochi.catalog.model.entity.Genre; import com.magamochi.catalog.model.entity.Genre;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface GenreRepository extends JpaRepository<Genre, Long> { public interface GenreRepository extends JpaRepository<Genre, Long> {}
Optional<Genre> findByMalId(Long malId);
Optional<Genre> findByName(String name);
}

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 java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; 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; import org.springframework.data.jpa.repository.JpaRepository;
public interface MangaAlternativeTitlesRepository 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.catalog.model.entity.Author;
import com.magamochi.model.entity.Manga; import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.model.entity.MangaAuthor; import com.magamochi.catalog.model.entity.MangaAuthor;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

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.catalog.model.entity.Genre;
import com.magamochi.model.entity.Manga; import com.magamochi.catalog.model.entity.Manga;
import com.magamochi.model.entity.MangaGenre; import com.magamochi.catalog.model.entity.MangaGenre;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

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.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; 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.common.exception.NotFoundException;
import com.magamochi.model.entity.Language;
import com.magamochi.model.repository.LanguageRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; 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.apache.commons.lang3.StringUtils.isBlank;
import static org.springframework.util.CollectionUtils.isEmpty;
import com.magamochi.model.dto.TitleMatchRequestDTO; import lombok.Builder;
import com.magamochi.model.dto.TitleMatchResponseDTO;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.apache.commons.text.similarity.LevenshteinDistance; import org.apache.commons.text.similarity.LevenshteinDistance;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -14,18 +13,24 @@ import org.springframework.stereotype.Service;
public class TitleMatcherService { public class TitleMatcherService {
private final LevenshteinDistance levenshteinDistance = LevenshteinDistance.getDefaultInstance(); private final LevenshteinDistance levenshteinDistance = LevenshteinDistance.getDefaultInstance();
public TitleMatchResponseDTO findBestMatch(TitleMatchRequestDTO request) { public TitleMatchResponse findBestMatch(TitleMatchRequest request) {
if (isBlank(request.getTitle()) || isEmpty(request.getOptions())) { if (isBlank(request.title()) || !request.options().iterator().hasNext()) {
throw new IllegalArgumentException("Title and options are required"); throw new IllegalArgumentException("Title and options are required");
} }
log.info("Finding best match for {}. Options: {}", request.getTitle(), request.getOptions()); // Set the default threshold if not specified
var threshold = request.threshold();
if (isNull(threshold) || threshold == 0) {
threshold = 85;
}
log.info("Finding best match for {}. Options: {}", request.title(), request.options());
String bestMatch = null; String bestMatch = null;
double bestScore = 0.0; double bestScore = 0.0;
for (var option : request.getOptions()) { for (var option : request.options()) {
var score = calculateSimilarityScore(request.getTitle(), option); var score = calculateSimilarityScore(request.title(), option);
if (score > bestScore) { if (score > bestScore) {
bestScore = score; bestScore = score;
@ -33,20 +38,20 @@ public class TitleMatcherService {
} }
} }
if (bestScore >= request.getThreshold()) { if (bestScore >= threshold) {
log.info( log.info(
"Found best match for {}: {}. Similarity: {}", request.getTitle(), bestMatch, bestScore); "Found best match for {}: {}. Similarity: {}", request.title(), bestMatch, bestScore);
return TitleMatchResponseDTO.builder() return TitleMatchResponse.builder()
.matchFound(true) .matchFound(true)
.bestMatch(bestMatch) .bestMatch(bestMatch)
.similarity(bestScore) .similarity(bestScore)
.build(); .build();
} }
log.info("No match found for {}. Threshold: {}", request.getTitle(), request.getThreshold()); log.info("No match found for {}. Threshold: {}", request.title(), threshold);
return TitleMatchResponseDTO.builder().matchFound(false).build(); return TitleMatchResponse.builder().matchFound(false).build();
} }
private double calculateSimilarityScore(String title, String option) { private double calculateSimilarityScore(String title, String option) {
@ -64,4 +69,10 @@ public class TitleMatcherService {
// Format to two decimal places for a cleaner result // Format to two decimal places for a cleaner result
return Math.round(similarity * 100.0) / 100.0; return Math.round(similarity * 100.0) / 100.0;
} }
@Builder
public record TitleMatchRequest(String title, Iterable<String> options, Integer threshold) {}
@Builder
public record TitleMatchResponse(boolean matchFound, String bestMatch, Double similarity) {}
} }

View File

@ -1,4 +1,4 @@
package com.magamochi.util; package com.magamochi.catalog.util;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; 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}") @Value("${queues.provider-page-ingest}")
private String providerPageIngestQueue; 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 @Bean
public Queue mangaIngestQueue() { public Queue mangaIngestQueue() {
return new Queue(mangaIngestQueue, false); return new Queue(mangaIngestQueue, false);

View File

@ -1,6 +1,6 @@
package com.magamochi.common.exception; package com.magamochi.common.exception;
import com.magamochi.common.dto.ErrorResponseDTO; import com.magamochi.common.model.dto.ErrorResponseDTO;
import java.time.Instant; import java.time.Instant;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;

View File

@ -1,4 +1,4 @@
package com.magamochi.common.dto; package com.magamochi.common.model.dto;
import jakarta.annotation.Nullable; import jakarta.annotation.Nullable;
import java.time.Instant; 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.NotBlank;
import jakarta.validation.constraints.NotNull; 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); return RateLimiter.create(1);
} }
@Bean
public RateLimiter aniListRateLimiter() {
return RateLimiter.create(0.5);
}
@Bean @Bean
public RateLimiter imageDownloadRateLimiter() { public RateLimiter imageDownloadRateLimiter() {
return RateLimiter.create(10); return RateLimiter.create(10);

View File

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

View File

@ -1,11 +1,9 @@
package com.magamochi.controller; package com.magamochi.controller;
import com.magamochi.client.NtfyClient; 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.ingestion.task.IngestFromContentProvidersTask;
import com.magamochi.model.dto.UpdateMangaDataCommand;
import com.magamochi.model.repository.UserRepository; import com.magamochi.model.repository.UserRepository;
import com.magamochi.queue.UpdateMangaDataProducer;
import com.magamochi.task.ImageCleanupTask; import com.magamochi.task.ImageCleanupTask;
import com.magamochi.task.MangaFollowUpdateTask; import com.magamochi.task.MangaFollowUpdateTask;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@ -21,19 +19,6 @@ public class ManagementController {
private final MangaFollowUpdateTask mangaFollowUpdateTask; private final MangaFollowUpdateTask mangaFollowUpdateTask;
private final UserRepository userRepository; private final UserRepository userRepository;
private final NtfyClient ntfyClient; 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( @Operation(
summary = "Cleanup unused S3 images", summary = "Cleanup unused S3 images",

View File

@ -1,6 +1,6 @@
package com.magamochi.controller; 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.dto.MangaChapterImagesDTO;
import com.magamochi.model.enumeration.ArchiveFileType; import com.magamochi.model.enumeration.ArchiveFileType;
import com.magamochi.service.MangaChapterService; import com.magamochi.service.MangaChapterService;

View File

@ -1,48 +1,18 @@
package com.magamochi.controller; 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.MangaChapterDTO;
import com.magamochi.model.dto.MangaDTO; import com.magamochi.service.OldMangaService;
import com.magamochi.model.dto.MangaListDTO;
import com.magamochi.model.dto.MangaListFilterDTO;
import com.magamochi.service.MangaService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; 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.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/mangas") @RequestMapping("/mangas")
@RequiredArgsConstructor @RequiredArgsConstructor
public class MangaController { public class MangaController {
private final MangaService mangaService; private final OldMangaService oldMangaService;
@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));
}
@Operation( @Operation(
summary = "Get the available chapters for a specific manga/provider combination", summary = "Get the available chapters for a specific manga/provider combination",
@ -52,7 +22,7 @@ public class MangaController {
@GetMapping("/{mangaProviderId}/chapters") @GetMapping("/{mangaProviderId}/chapters")
public DefaultResponseDTO<List<MangaChapterDTO>> getMangaChapters( public DefaultResponseDTO<List<MangaChapterDTO>> getMangaChapters(
@PathVariable Long mangaProviderId) { @PathVariable Long mangaProviderId) {
return DefaultResponseDTO.ok(mangaService.getMangaChapters(mangaProviderId)); return DefaultResponseDTO.ok(oldMangaService.getMangaChapters(mangaProviderId));
} }
@Operation( @Operation(
@ -62,7 +32,7 @@ public class MangaController {
operationId = "fetchAllChapters") operationId = "fetchAllChapters")
@PostMapping(value = "/{mangaProviderId}/fetch-all-chapters") @PostMapping(value = "/{mangaProviderId}/fetch-all-chapters")
public DefaultResponseDTO<Void> fetchAllChapters(@PathVariable Long mangaProviderId) { public DefaultResponseDTO<Void> fetchAllChapters(@PathVariable Long mangaProviderId) {
mangaService.fetchAllNotDownloadedChapters(mangaProviderId); oldMangaService.fetchAllNotDownloadedChapters(mangaProviderId);
return DefaultResponseDTO.ok().build(); return DefaultResponseDTO.ok().build();
} }
@ -74,7 +44,7 @@ public class MangaController {
operationId = "fetchMangaChapters") operationId = "fetchMangaChapters")
@PostMapping("/{mangaProviderId}/fetch-chapters") @PostMapping("/{mangaProviderId}/fetch-chapters")
public DefaultResponseDTO<Void> fetchMangaChapters(@PathVariable Long mangaProviderId) { public DefaultResponseDTO<Void> fetchMangaChapters(@PathVariable Long mangaProviderId) {
mangaService.fetchMangaChapters(mangaProviderId); oldMangaService.fetchMangaChapters(mangaProviderId);
return DefaultResponseDTO.ok().build(); return DefaultResponseDTO.ok().build();
} }
@ -86,7 +56,7 @@ public class MangaController {
operationId = "followManga") operationId = "followManga")
@PostMapping("/{mangaId}/followManga") @PostMapping("/{mangaId}/followManga")
public DefaultResponseDTO<Void> followManga(@PathVariable Long mangaId) { public DefaultResponseDTO<Void> followManga(@PathVariable Long mangaId) {
mangaService.follow(mangaId); oldMangaService.follow(mangaId);
return DefaultResponseDTO.ok().build(); return DefaultResponseDTO.ok().build();
} }
@ -98,7 +68,7 @@ public class MangaController {
operationId = "unfollowManga") operationId = "unfollowManga")
@PostMapping("/{mangaId}/unfollowManga") @PostMapping("/{mangaId}/unfollowManga")
public DefaultResponseDTO<Void> unfollowManga(@PathVariable Long mangaId) { public DefaultResponseDTO<Void> unfollowManga(@PathVariable Long mangaId) {
mangaService.unfollow(mangaId); oldMangaService.unfollow(mangaId);
return DefaultResponseDTO.ok().build(); return DefaultResponseDTO.ok().build();
} }

View File

@ -1,9 +1,9 @@
package com.magamochi.controller; 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.ImportMangaResponseDTO;
import com.magamochi.model.dto.ImportRequestDTO; import com.magamochi.model.dto.ImportRequestDTO;
import com.magamochi.service.MangaImportService; // import com.magamochi.service.MangaImportService;
import com.magamochi.service.ProviderManualMangaImportService; import com.magamochi.service.ProviderManualMangaImportService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@ -20,7 +20,7 @@ import org.springframework.web.multipart.MultipartFile;
@RequestMapping("/manga/import") @RequestMapping("/manga/import")
@RequiredArgsConstructor @RequiredArgsConstructor
public class MangaImportController { public class MangaImportController {
private final MangaImportService mangaImportService; // private final MangaImportService mangaImportService;
private final ProviderManualMangaImportService providerManualMangaImportService; private final ProviderManualMangaImportService providerManualMangaImportService;
@Operation( @Operation(
@ -55,7 +55,7 @@ public class MangaImportController {
@RequestPart("files") @RequestPart("files")
@NotNull @NotNull
List<MultipartFile> files) { List<MultipartFile> files) {
mangaImportService.importMangaFiles(malId, files); // mangaImportService.importMangaFiles(malId, files);
return DefaultResponseDTO.ok().build(); return DefaultResponseDTO.ok().build();
} }

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; package com.magamochi.controller;
import com.magamochi.common.dto.DefaultResponseDTO; import com.magamochi.common.model.dto.DefaultResponseDTO;
import com.magamochi.service.UserFavoriteMangaService; import com.magamochi.service.UserFavoriteMangaService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;

View File

@ -1,6 +1,6 @@
package com.magamochi.ingestion.controller; 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.model.dto.ContentProviderListDTO;
import com.magamochi.ingestion.service.ContentProviderService; import com.magamochi.ingestion.service.ContentProviderService;
import com.magamochi.ingestion.service.IngestionService; import com.magamochi.ingestion.service.IngestionService;

View File

@ -1,6 +1,5 @@
package com.magamochi.ingestion.model.dto; package com.magamochi.ingestion.model.dto;
import com.magamochi.model.enumeration.MangaStatus;
import jakarta.validation.constraints.NotBlank; 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.ingestion.providers.PagedContentProvider;
import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO; import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.model.entity.MangaContentProvider; import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.enumeration.MangaStatus;
import java.io.IOException; import java.io.IOException;
import java.util.*; import java.util.*;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@ -116,25 +115,13 @@ public class MangaLivreBlogProvider implements ContentProvider, PagedContentProv
try { try {
var linkElement = element.getElementsByTag("a").getFirst(); var linkElement = element.getElementsByTag("a").getFirst();
var imageContainer =
linkElement.getElementsByClass("manga-card-image").getFirst();
var contentContainer = var contentContainer =
linkElement.getElementsByClass("manga-card-content").getFirst(); linkElement.getElementsByClass("manga-card-content").getFirst();
var title = contentContainer.getElementsByTag("h3").text(); var title = contentContainer.getElementsByTag("h3").text();
var url = linkElement.attr("href"); 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) { } catch (Exception e) {
return null; return null;
} }

View File

@ -9,7 +9,6 @@ import com.magamochi.ingestion.providers.PagedContentProvider;
import com.magamochi.ingestion.service.FlareService; import com.magamochi.ingestion.service.FlareService;
import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO; import com.magamochi.model.dto.ContentProviderMangaChapterResponseDTO;
import com.magamochi.model.entity.MangaContentProvider; import com.magamochi.model.entity.MangaContentProvider;
import com.magamochi.model.enumeration.MangaStatus;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.IntStream; 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(); .toList();
} catch (NoSuchElementException e) { } catch (NoSuchElementException e) {

View File

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

View File

@ -2,7 +2,7 @@ package com.magamochi.model.dto;
import static java.util.Objects.isNull; 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.NotBlank;
import jakarta.validation.constraints.NotNull; 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; package com.magamochi.model.entity;
import com.magamochi.catalog.model.entity.Language;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package com.magamochi.model.entity; package com.magamochi.model.entity;
import com.magamochi.catalog.model.entity.Manga;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; 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; 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.User;
import com.magamochi.model.entity.UserFavoriteManga; import com.magamochi.model.entity.UserFavoriteManga;
import java.util.Optional; import java.util.Optional;

View File

@ -1,6 +1,6 @@
package com.magamochi.model.repository; 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.User;
import com.magamochi.model.entity.UserMangaFollow; import com.magamochi.model.entity.UserMangaFollow;
import java.util.List; import java.util.List;

View File

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

View File

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

View File

@ -1,9 +1,9 @@
package com.magamochi.service; 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.common.exception.NotFoundException;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.entity.UserFavoriteManga; import com.magamochi.model.entity.UserFavoriteManga;
import com.magamochi.model.repository.MangaRepository;
import com.magamochi.model.repository.UserFavoriteMangaRepository; import com.magamochi.model.repository.UserFavoriteMangaRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View File

@ -1,8 +1,8 @@
package com.magamochi.task; 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.dto.UpdateMangaFollowChapterListCommand;
import com.magamochi.model.entity.Manga;
import com.magamochi.model.repository.MangaRepository;
import com.magamochi.queue.UpdateMangaFollowChapterListProducer; import com.magamochi.queue.UpdateMangaFollowChapterListProducer;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;

View File

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

View File

@ -34,6 +34,7 @@ CREATE TABLE mangas
published_to TIMESTAMPTZ, published_to TIMESTAMPTZ,
chapter_count INT DEFAULT 0, chapter_count INT DEFAULT 0,
follow BOOLEAN NOT NULL DEFAULT FALSE, follow BOOLEAN NOT NULL DEFAULT FALSE,
state VARCHAR NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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, url VARCHAR NOT NULL,
chapter_number INTEGER, chapter_number INTEGER,
language_id BIGINT REFERENCES languages (id), language_id BIGINT REFERENCES languages (id),
downloaded BOOLEAN DEFAULT FALSE,
read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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, manga_chapter_id BIGINT NOT NULL REFERENCES manga_chapters (id) ON DELETE CASCADE,
image_id UUID REFERENCES images (id) ON DELETE CASCADE, image_id UUID REFERENCES images (id) ON DELETE CASCADE,
position INT NOT NULL, position INT NOT NULL,
downloaded BOOLEAN DEFAULT FALSE,
read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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, id BIGSERIAL NOT NULL PRIMARY KEY,
content_provider_id BIGINT NOT NULL REFERENCES content_providers (id) ON DELETE CASCADE, 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, url VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@ -98,7 +99,6 @@ CREATE TABLE manga_import_reviews
CREATE TABLE authors CREATE TABLE authors
( (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
mal_id BIGINT UNIQUE,
name VARCHAR, name VARCHAR,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
@ -114,9 +114,8 @@ CREATE TABLE manga_author
CREATE TABLE genres CREATE TABLE genres
( (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
mal_id BIGINT UNIQUE, name VARCHAR
name VARCHAR
); );
CREATE TABLE manga_genre CREATE TABLE manga_genre