feat: save alternative titles, genres and authors #29
@ -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) {}
|
||||
|
||||
|
||||
@ -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<MangaDTO> 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<Void> 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<Void> updateMangas(@PathVariable Long mangaId) {
|
||||
mangaUpdateService.updateManga(mangaId);
|
||||
|
||||
return DefaultResponseDTO.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<MangaAuthor> mangaAuthors;
|
||||
@OneToMany(mappedBy = "manga", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@Builder.Default
|
||||
private List<MangaAuthor> mangaAuthors = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "manga")
|
||||
private List<MangaGenre> mangaGenres;
|
||||
@OneToMany(mappedBy = "manga", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@Builder.Default
|
||||
private List<MangaGenre> mangaGenres = new ArrayList<>();
|
||||
|
||||
;
|
||||
|
||||
@OneToMany(mappedBy = "manga", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@Builder.Default
|
||||
private List<MangaAlternativeTitle> alternativeTitles = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "manga")
|
||||
private List<UserFavoriteManga> userFavoriteMangas;
|
||||
|
||||
@OneToMany(mappedBy = "manga")
|
||||
private List<MangaAlternativeTitle> alternativeTitles;
|
||||
private <T> void synchronizeCollection(
|
||||
List<T> currentList,
|
||||
List<T> newList,
|
||||
BiConsumer<T, Manga> parentSetter,
|
||||
BiPredicate<T, T> 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<MangaGenre> newGenres) {
|
||||
synchronizeCollection(
|
||||
this.mangaGenres,
|
||||
newGenres,
|
||||
MangaGenre::setManga,
|
||||
(existing, next) -> existing.getGenre().getId().equals(next.getGenre().getId()));
|
||||
}
|
||||
|
||||
public void updateAuthors(List<MangaAuthor> newAuthors) {
|
||||
synchronizeCollection(
|
||||
this.mangaAuthors,
|
||||
newAuthors,
|
||||
MangaAuthor::setManga,
|
||||
(existing, next) -> existing.getAuthor().getId().equals(next.getAuthor().getId()));
|
||||
}
|
||||
|
||||
public void updateAlternativeTitles(List<MangaAlternativeTitle> newTitles) {
|
||||
synchronizeCollection(
|
||||
this.alternativeTitles,
|
||||
newTitles,
|
||||
MangaAlternativeTitle::setManga,
|
||||
(existing, next) -> existing.getTitle().equalsIgnoreCase(next.getTitle()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Author, Long> {}
|
||||
public interface AuthorRepository extends JpaRepository<Author, Long> {
|
||||
Optional<Author> findByNameIgnoreCase(String name);
|
||||
}
|
||||
|
||||
@ -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<Genre, Long> {}
|
||||
public interface GenreRepository extends JpaRepository<Genre, Long> {
|
||||
Optional<Genre> findByNameIgnoreCase(String name);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Manga> findAll() {
|
||||
return mangaRepository.findAll();
|
||||
}
|
||||
|
||||
public Page<MangaListDTO> get(MangaListFilterDTO filterDTO, Pageable pageable) {
|
||||
var user = userService.getLoggedUser();
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user