diff --git a/src/main/java/com/magamochi/catalog/client/AniListClient.java b/src/main/java/com/magamochi/catalog/client/AniListClient.java index 754fa8f..c2404ac 100644 --- a/src/main/java/com/magamochi/catalog/client/AniListClient.java +++ b/src/main/java/com/magamochi/catalog/client/AniListClient.java @@ -49,7 +49,7 @@ public interface AniListClient { public record Title( String romaji, String english, @JsonProperty("native") String nativeTitle) {} - public record CoverImage(String large) {} + public record CoverImage(String large, String extraLarge) {} public record FuzzyDate(Integer year, Integer month, Integer day) {} diff --git a/src/main/java/com/magamochi/catalog/controller/CatalogController.java b/src/main/java/com/magamochi/catalog/controller/CatalogController.java index 7e1a21e..fd74b80 100644 --- a/src/main/java/com/magamochi/catalog/controller/CatalogController.java +++ b/src/main/java/com/magamochi/catalog/controller/CatalogController.java @@ -6,6 +6,7 @@ import com.magamochi.catalog.model.dto.MangaListDTO; import com.magamochi.catalog.model.dto.MangaListFilterDTO; import com.magamochi.catalog.service.GenreService; import com.magamochi.catalog.service.MangaService; +import com.magamochi.catalog.service.MangaUpdateService; import com.magamochi.common.model.dto.DefaultResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.*; public class CatalogController { private final GenreService genreService; private final MangaService mangaService; + private final MangaUpdateService mangaUpdateService; @Operation( summary = "Get a list of manga genres", @@ -55,4 +57,28 @@ public class CatalogController { public DefaultResponseDTO getManga(@PathVariable Long mangaId) { return DefaultResponseDTO.ok(mangaService.get(mangaId)); } + + @Operation( + summary = "Update all manga data", + description = "Update all manga's metadata and cover", + tags = {"Catalog"}, + operationId = "updateMangas") + @PostMapping("/mangas/update") + public DefaultResponseDTO updateMangas() { + mangaUpdateService.updateMangas(); + + return DefaultResponseDTO.ok().build(); + } + + @Operation( + summary = "Update manga data", + description = "Update a manga's metadata and cover", + tags = {"Catalog"}, + operationId = "updateMangas") + @PostMapping("/mangas/{mangaId}/update") + public DefaultResponseDTO updateMangas(@PathVariable Long mangaId) { + mangaUpdateService.updateManga(mangaId); + + return DefaultResponseDTO.ok().build(); + } } diff --git a/src/main/java/com/magamochi/catalog/model/entity/Manga.java b/src/main/java/com/magamochi/catalog/model/entity/Manga.java index 8662417..bc1eef5 100644 --- a/src/main/java/com/magamochi/catalog/model/entity/Manga.java +++ b/src/main/java/com/magamochi/catalog/model/entity/Manga.java @@ -7,7 +7,10 @@ import com.magamochi.model.entity.UserFavoriteManga; import jakarta.persistence.*; import java.time.Instant; import java.time.OffsetDateTime; +import java.util.ArrayList; import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; import lombok.*; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; @@ -60,15 +63,72 @@ public class Manga { @UpdateTimestamp private Instant updatedAt; - @OneToMany(mappedBy = "manga") - private List mangaAuthors; + @OneToMany(mappedBy = "manga", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List mangaAuthors = new ArrayList<>(); - @OneToMany(mappedBy = "manga") - private List mangaGenres; + @OneToMany(mappedBy = "manga", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List mangaGenres = new ArrayList<>(); + + ; + + @OneToMany(mappedBy = "manga", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List alternativeTitles = new ArrayList<>(); @OneToMany(mappedBy = "manga") private List userFavoriteMangas; - @OneToMany(mappedBy = "manga") - private List alternativeTitles; + private void synchronizeCollection( + List currentList, + List newList, + BiConsumer parentSetter, + BiPredicate equalityCheck) { + + if (newList == null) { + if (currentList != null) currentList.clear(); + return; + } + + // 1. Remove items no longer present in the new list + currentList.removeIf( + existing -> newList.stream().noneMatch(newItem -> equalityCheck.test(existing, newItem))); + + // 2. Add only items that aren't already in the current list + newList.forEach( + newItem -> { + boolean alreadyExists = + currentList.stream().anyMatch(existing -> equalityCheck.test(existing, newItem)); + + if (!alreadyExists) { + parentSetter.accept(newItem, this); + currentList.add(newItem); + } + }); + } + + public void updateGenres(List newGenres) { + synchronizeCollection( + this.mangaGenres, + newGenres, + MangaGenre::setManga, + (existing, next) -> existing.getGenre().getId().equals(next.getGenre().getId())); + } + + public void updateAuthors(List newAuthors) { + synchronizeCollection( + this.mangaAuthors, + newAuthors, + MangaAuthor::setManga, + (existing, next) -> existing.getAuthor().getId().equals(next.getAuthor().getId())); + } + + public void updateAlternativeTitles(List newTitles) { + synchronizeCollection( + this.alternativeTitles, + newTitles, + MangaAlternativeTitle::setManga, + (existing, next) -> existing.getTitle().equalsIgnoreCase(next.getTitle())); + } } diff --git a/src/main/java/com/magamochi/catalog/model/repository/AuthorRepository.java b/src/main/java/com/magamochi/catalog/model/repository/AuthorRepository.java index d763ac4..ff5a6e4 100644 --- a/src/main/java/com/magamochi/catalog/model/repository/AuthorRepository.java +++ b/src/main/java/com/magamochi/catalog/model/repository/AuthorRepository.java @@ -1,6 +1,9 @@ package com.magamochi.catalog.model.repository; import com.magamochi.catalog.model.entity.Author; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface AuthorRepository extends JpaRepository {} +public interface AuthorRepository extends JpaRepository { + Optional findByNameIgnoreCase(String name); +} diff --git a/src/main/java/com/magamochi/catalog/model/repository/GenreRepository.java b/src/main/java/com/magamochi/catalog/model/repository/GenreRepository.java index 0fd67b3..8029a9a 100644 --- a/src/main/java/com/magamochi/catalog/model/repository/GenreRepository.java +++ b/src/main/java/com/magamochi/catalog/model/repository/GenreRepository.java @@ -1,6 +1,9 @@ package com.magamochi.catalog.model.repository; import com.magamochi.catalog.model.entity.Genre; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface GenreRepository extends JpaRepository {} +public interface GenreRepository extends JpaRepository { + Optional findByNameIgnoreCase(String name); +} diff --git a/src/main/java/com/magamochi/catalog/service/AniListService.java b/src/main/java/com/magamochi/catalog/service/AniListService.java index fbd9589..d885d37 100644 --- a/src/main/java/com/magamochi/catalog/service/AniListService.java +++ b/src/main/java/com/magamochi/catalog/service/AniListService.java @@ -85,7 +85,10 @@ public class AniListService { ? List.of(media.title().english(), media.title().nativeTitle()) : List.of(media.title().nativeTitle()) : List.of(media.title().nativeTitle())) - .coverImageUrl(media.coverImage().large()) + .coverImageUrl( + nonNull(media.coverImage().extraLarge()) + ? media.coverImage().extraLarge() + : media.coverImage().large()) .status(mapStatus(media.status())) .build(); } @@ -106,7 +109,7 @@ public class AniListService { status averageScore chapters - coverImage { large } + coverImage { large, extraLarge } genres staff { edges { @@ -167,6 +170,8 @@ public class AniListService { return switch (aniListStatus.toLowerCase()) { case "releasing" -> MangaStatus.ONGOING; case "finished" -> MangaStatus.COMPLETED; + case "cancelled" -> MangaStatus.CANCELLED; + case "hiatus" -> MangaStatus.HIATUS; default -> MangaStatus.UNKNOWN; }; } diff --git a/src/main/java/com/magamochi/catalog/service/AuthorService.java b/src/main/java/com/magamochi/catalog/service/AuthorService.java new file mode 100644 index 0000000..f46b37c --- /dev/null +++ b/src/main/java/com/magamochi/catalog/service/AuthorService.java @@ -0,0 +1,18 @@ +package com.magamochi.catalog.service; + +import com.magamochi.catalog.model.entity.Author; +import com.magamochi.catalog.model.repository.AuthorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthorService { + private final AuthorRepository authorRepository; + + public Author findOrCreateAuthor(String authorName) { + return authorRepository + .findByNameIgnoreCase(authorName) + .orElseGet(() -> authorRepository.save(Author.builder().name(authorName).build())); + } +} diff --git a/src/main/java/com/magamochi/catalog/service/GenreService.java b/src/main/java/com/magamochi/catalog/service/GenreService.java index d2ba420..2eeffab 100644 --- a/src/main/java/com/magamochi/catalog/service/GenreService.java +++ b/src/main/java/com/magamochi/catalog/service/GenreService.java @@ -1,6 +1,7 @@ package com.magamochi.catalog.service; import com.magamochi.catalog.model.dto.GenreDTO; +import com.magamochi.catalog.model.entity.Genre; import com.magamochi.catalog.model.repository.GenreRepository; import java.util.List; import lombok.RequiredArgsConstructor; @@ -16,4 +17,10 @@ public class GenreService { return genres.stream().map(GenreDTO::from).toList(); } + + public Genre findOrCreateGenre(String genreName) { + return genreRepository + .findByNameIgnoreCase(genreName) + .orElseGet(() -> genreRepository.save(Genre.builder().name(genreName).build())); + } } diff --git a/src/main/java/com/magamochi/catalog/service/MangaService.java b/src/main/java/com/magamochi/catalog/service/MangaService.java index 0439c43..1e77e83 100644 --- a/src/main/java/com/magamochi/catalog/service/MangaService.java +++ b/src/main/java/com/magamochi/catalog/service/MangaService.java @@ -12,6 +12,7 @@ import com.magamochi.model.repository.UserFavoriteMangaRepository; import com.magamochi.model.repository.UserMangaFollowRepository; import com.magamochi.model.specification.MangaSpecification; import com.magamochi.user.service.UserService; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -34,6 +35,10 @@ public class MangaService { .orElseThrow(() -> new NotFoundException("Manga with ID " + mangaId + " not found")); } + public List findAll() { + return mangaRepository.findAll(); + } + public Page get(MangaListFilterDTO filterDTO, Pageable pageable) { var user = userService.getLoggedUser(); diff --git a/src/main/java/com/magamochi/catalog/service/MangaUpdateService.java b/src/main/java/com/magamochi/catalog/service/MangaUpdateService.java index a133a3d..cc26912 100644 --- a/src/main/java/com/magamochi/catalog/service/MangaUpdateService.java +++ b/src/main/java/com/magamochi/catalog/service/MangaUpdateService.java @@ -4,7 +4,12 @@ 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.entity.MangaAlternativeTitle; +import com.magamochi.catalog.model.entity.MangaAuthor; +import com.magamochi.catalog.model.entity.MangaGenre; import com.magamochi.catalog.model.enumeration.MangaState; +import com.magamochi.catalog.queue.command.MangaUpdateCommand; +import com.magamochi.catalog.queue.producer.MangaUpdateProducer; import com.magamochi.common.model.enumeration.ContentType; import com.magamochi.common.queue.command.ImageFetchCommand; import com.magamochi.common.queue.producer.ImageFetchProducer; @@ -23,9 +28,22 @@ public class MangaUpdateService { private final MyAnimeListService myAnimeListService; private final MangaService mangaService; private final ImageService imageService; + private final GenreService genreService; + private final AuthorService authorService; + private final MangaUpdateProducer mangaUpdateProducer; private final ImageFetchProducer imageFetchProducer; + public void updateMangas() { + var mangas = mangaService.findAll(); + + mangas.forEach(manga -> updateManga(manga.getId())); + } + + public void updateManga(Long mangaId) { + mangaUpdateProducer.sendMangaUpdateCommand(new MangaUpdateCommand(mangaId)); + } + @Transactional public void update(long mangaId) { log.info("Updating manga with ID {}", mangaId); @@ -66,6 +84,29 @@ public class MangaUpdateService { } private void applyUpdatesToDatabase(Manga manga, MangaDataDTO mangaData) { + var alternativeTitles = + mangaData.alternativeTitles().stream() + .map(title -> MangaAlternativeTitle.builder().title(title).manga(manga).build()) + .toList(); + + var genres = + mangaData.genres().stream() + .map( + genreName -> { + var genre = genreService.findOrCreateGenre(genreName); + return MangaGenre.builder().manga(manga).genre(genre).build(); + }) + .toList(); + + var authors = + mangaData.authors().stream() + .map( + authorName -> { + var author = authorService.findOrCreateAuthor(authorName); + return MangaAuthor.builder().manga(manga).author(author).build(); + }) + .toList(); + manga.setTitle(mangaData.title()); manga.setSynopsis(mangaData.synopsis()); manga.setScore(mangaData.score()); @@ -74,22 +115,8 @@ public class MangaUpdateService { 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); + manga.updateAlternativeTitles(alternativeTitles); + manga.updateGenres(genres); + manga.updateAuthors(authors); } } diff --git a/src/main/java/com/magamochi/common/config/RabbitConfig.java b/src/main/java/com/magamochi/common/config/RabbitConfig.java index 912b9ba..9f1065f 100644 --- a/src/main/java/com/magamochi/common/config/RabbitConfig.java +++ b/src/main/java/com/magamochi/common/config/RabbitConfig.java @@ -40,6 +40,9 @@ public class RabbitConfig { @Value("${topics.image-updates}") private String imageUpdatesTopic; + @Value("${routing-key.image-update}") + private String imageUpdateRoutingKey; + @Bean public TopicExchange imageUpdatesExchange() { return new TopicExchange(imageUpdatesTopic); @@ -72,7 +75,7 @@ public class RabbitConfig { mangaCoverUpdateQueue.getName(), Binding.DestinationType.QUEUE, imageUpdatesExchange.getName(), - String.format("image.update.%s", ContentType.MANGA_COVER.name().toLowerCase()), + String.format(imageUpdateRoutingKey + ".%s", ContentType.MANGA_COVER.name().toLowerCase()), null); } @@ -83,7 +86,8 @@ public class RabbitConfig { mangaContentImageUpdateQueue.getName(), Binding.DestinationType.QUEUE, imageUpdatesExchange.getName(), - String.format("image.update.%s", ContentType.CONTENT_IMAGE.name().toLowerCase()), + String.format( + imageUpdateRoutingKey + ".%s", ContentType.CONTENT_IMAGE.name().toLowerCase()), null); } diff --git a/src/main/java/com/magamochi/image/queue/producer/ImageUpdateProducer.java b/src/main/java/com/magamochi/image/queue/producer/ImageUpdateProducer.java index 89964f1..e5540f4 100644 --- a/src/main/java/com/magamochi/image/queue/producer/ImageUpdateProducer.java +++ b/src/main/java/com/magamochi/image/queue/producer/ImageUpdateProducer.java @@ -17,8 +17,11 @@ public class ImageUpdateProducer { @Value("${topics.image-updates}") private String imageUpdatesTopic; + @Value("${routing-key.image-update}") + private String imageUpdateRoutingKey; + public void publishImageUpdateCommand(ImageUpdateCommand command, ContentType contentType) { - var routingKey = String.format("image.update.%s", contentType.name().toLowerCase()); + var routingKey = String.format(imageUpdateRoutingKey + ".%s", contentType.name().toLowerCase()); rabbitTemplate.convertAndSend(imageUpdatesTopic, routingKey, command); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7613222..0f0c4e4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,6 +69,9 @@ queues: image-fetch: ${IMAGE_FETCH_QUEUE:mangamochi.image.fetch} manga-cover-update: ${MANGA_COVER_UDPATE_QUEUE:mangamochi.manga.cover.update} +routing-key: + image-update: ${IMAGE_UPDATE_ROUTING_KEY:mangamochi.image.update} + rabbit-mq: queues: manga-chapter-download: ${MANGA_CHAPTER_DOWNLOAD_QUEUE:mangaChapterDownloadQueue}