feat: save alternative titles, genres and authors #29

Merged
rov merged 2 commits from refactor-architecture into main 2026-03-18 19:23:57 -03:00
13 changed files with 195 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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