From 4f3d955879a3b2cfc1eec5a22da9b7b0b1c73b09 Mon Sep 17 00:00:00 2001 From: bivashy Date: Tue, 24 Mar 2026 04:27:51 +0500 Subject: [PATCH] Implement basic detail endpoint --- .../backend/unifier/title/api/KodikAPI.java | 5 - .../mapper/KodikAndShikimoriDetailMapper.java | 149 ++++++++++++++++++ .../title/model/ContentDetailEntry.java | 43 ++++- .../title/resource/DetailResource.java | 18 ++- .../unifier/title/service/DetailService.java | 9 ++ .../title/service/GeneralDetailService.java | 34 ++++ .../service/kodik/KodikDetailService.java | 47 ++++++ 7 files changed, 296 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/backend/unifier/title/mapper/KodikAndShikimoriDetailMapper.java create mode 100644 src/main/java/com/backend/unifier/title/service/DetailService.java create mode 100644 src/main/java/com/backend/unifier/title/service/GeneralDetailService.java create mode 100644 src/main/java/com/backend/unifier/title/service/kodik/KodikDetailService.java diff --git a/src/main/java/com/backend/unifier/title/api/KodikAPI.java b/src/main/java/com/backend/unifier/title/api/KodikAPI.java index ef17629..a11d143 100644 --- a/src/main/java/com/backend/unifier/title/api/KodikAPI.java +++ b/src/main/java/com/backend/unifier/title/api/KodikAPI.java @@ -4,7 +4,6 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import com.backend.metadata.kodik.api.model.KodikResponse; -import io.smallrye.mutiny.Uni; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -36,8 +35,4 @@ public interface KodikAPI { @GET @Path("/imdb/{id}") KodikResponse findByImdbId(@PathParam("id") String id); - - @GET - @Path("/search") - Uni searchAsync(@QueryParam("title") String title); } diff --git a/src/main/java/com/backend/unifier/title/mapper/KodikAndShikimoriDetailMapper.java b/src/main/java/com/backend/unifier/title/mapper/KodikAndShikimoriDetailMapper.java new file mode 100644 index 0000000..32a6133 --- /dev/null +++ b/src/main/java/com/backend/unifier/title/mapper/KodikAndShikimoriDetailMapper.java @@ -0,0 +1,149 @@ +package com.backend.unifier.title.mapper; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.mapstruct.AfterMapping; +import org.mapstruct.Context; +import org.mapstruct.IterableMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.Named; +import org.mapstruct.NullValuePropertyMappingStrategy; + +import com.backend.metadata.kodik.api.model.KodikResponse; +import com.backend.metadata.kodik.api.model.KodikResponse.Result; +import com.backend.metadata.shikimori.api.model.Anime; +import com.backend.metadata.shikimori.api.model.CharacterRole; +import com.backend.metadata.shikimori.api.model.Genre; +import com.backend.metadata.shikimori.api.model.Related; +import com.backend.unifier.title.model.ContentDetailEntry; +import com.backend.unifier.title.model.ContentDetailEntry.CharacterDto; +import com.backend.unifier.title.model.ContentDetailEntry.RelatedDto; +import com.backend.unifier.title.model.ContentDetailEntry.TranslationDto; + +@Mapper(componentModel = "jakarta", nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) +public interface KodikAndShikimoriDetailMapper { + @Mapping(target = "title", source = "result.materialData.titleEn") + @Mapping(target = "kind", source = "result.materialData.animeKind") + @Mapping(target = "status", source = "result.materialData.animeStatus") + @Mapping(target = "origin", ignore = true) + @Mapping(target = "rating", source = "result.materialData.shikimoriRating") + @Mapping(target = "episodes", source = "result.materialData.episodesTotal") + @Mapping(target = "duration", source = "result.materialData.duration") + @Mapping(target = "releasedOn", source = "result.materialData.releasedAt") + @Mapping(target = "description", source = "result.materialData.animeDescription") + @Mapping(target = "posterUrl", source = "result.materialData.animePosterUrl") + @Mapping(target = "genres", source = "result.materialData.animeGenres") + @Mapping(target = "translations", ignore = true) + @Mapping(target = "studios", source = "result.materialData.animeStudios") + @Mapping(target = "producers", source = "result.materialData.producers") + @Mapping(target = "directors", source = "result.materialData.directors") + @Mapping(target = "related", ignore = true) + @Mapping(target = "characters", ignore = true) + ContentDetailEntry toDto(Result result, @Context KodikResponse allKodikResults, @Context Anime anime); + + @AfterMapping + default void mergeShikimoriAndKodik(@MappingTarget ContentDetailEntry dto, + Result result, + @Context KodikResponse allKodikResults, + @Context Anime anime) { + + if (allKodikResults != null && allKodikResults.results != null) { + Map seen = allKodikResults.results.stream() + .filter(r -> r.translation != null) + .collect(Collectors.toMap( + r -> r.translation.id, + r -> new TranslationDto(r.translation.id, r.translation.title, r.translation.type), + (a, b) -> a)); + dto.translations = List.copyOf(seen.values()); + } + + if (anime == null) + return; + + dto.related = toRelatedDtos(anime.getRelated()); + dto.characters = toCharacterDtos(anime.getCharacterRoles()); + + if (dto.origin == null && anime.getOrigin() != null) + dto.origin = anime.getOrigin().name(); + + if (dto.title == null) + dto.title = anime.getEnglish(); + + if (dto.kind == null) + dto.kind = anime.getKind(); + if (dto.rating == null && anime.getRating() != null) + dto.rating = anime.getRating().name(); + if (dto.episodes == null) + dto.episodes = anime.getEpisodes(); + if (dto.duration == null) + dto.duration = anime.getDuration(); + if (dto.description == null) + dto.description = anime.getDescription(); + + if (dto.posterUrl == null && anime.getPoster() != null) + dto.posterUrl = anime.getPoster().mainUrl(); + + if (dto.releasedOn == null && anime.getReleasedOn() != null && anime.getReleasedOn().getDate() != null) + dto.releasedOn = anime.getReleasedOn().getDate().toString(); + + if ((dto.genres == null || dto.genres.isEmpty()) && anime.getGenres() != null) + dto.genres = anime.getGenres().stream().map(Genre::name).toList(); + } + + @Named("toRelatedDto") + default RelatedDto toRelatedDto(Related related) { + if (related == null) + return null; + + String id; + String name; + String posterUrl; + + if (related.anime() != null) { + id = related.anime().id(); + name = related.anime().name(); + posterUrl = related.anime().poster().mainUrl(); + } else if (related.manga() != null) { + id = related.manga().id(); + name = related.manga().name(); + posterUrl = related.manga().poster().mainUrl(); + } else { + return null; + } + + return new RelatedDto( + id, + related.relationKind(), + name, + posterUrl); + } + + @IterableMapping(qualifiedByName = "toRelatedDto") + List toRelatedDtos(List related); + + @Named("toCharacterDto") + default CharacterDto toCharacterDto(CharacterRole role) { + if (role == null || role.character() == null) + return null; + + com.backend.metadata.shikimori.api.model.Character c = role.character(); + String type = firstOf(role.rolesEn()); + + return new CharacterDto( + c.id(), + type, + c.name(), + c.poster() != null ? c.poster().mainUrl() : null); + } + + @IterableMapping(qualifiedByName = "toCharacterDto") + List toCharacterDtos(List roles); + + default String firstOf(List list) { + return (list != null && !list.isEmpty()) ? list.get(0) : null; + } +} diff --git a/src/main/java/com/backend/unifier/title/model/ContentDetailEntry.java b/src/main/java/com/backend/unifier/title/model/ContentDetailEntry.java index c33d971..e142a89 100644 --- a/src/main/java/com/backend/unifier/title/model/ContentDetailEntry.java +++ b/src/main/java/com/backend/unifier/title/model/ContentDetailEntry.java @@ -1,4 +1,45 @@ package com.backend.unifier.title.model; -public record ContentDetailEntry() { +import java.util.List; + +public class ContentDetailEntry { + public String title; + public String posterUrl; + public String description; + public String kind; + + public Integer episodes; + public Integer duration; // minutes per episode + + public String status; // released, ongoing, anons + public String releasedOn; + public List translations; + public List genres; + public String origin; // manga, novel, original … + public String rating; // pg_13, r_plus, r … + public List producers; + + public List related; + public List characters; + + public List studios; + public List directors; + + public record TranslationDto(int id, String title, String type) { + } + + public record RelatedDto( + String id, + String type, + String name, + String posterUrl) { + } + + public record CharacterDto( + String id, + String type, + String name, + String posterUrl) { + } + } diff --git a/src/main/java/com/backend/unifier/title/resource/DetailResource.java b/src/main/java/com/backend/unifier/title/resource/DetailResource.java index be258d7..2b67ba5 100644 --- a/src/main/java/com/backend/unifier/title/resource/DetailResource.java +++ b/src/main/java/com/backend/unifier/title/resource/DetailResource.java @@ -1,16 +1,28 @@ package com.backend.unifier.title.resource; -import com.backend.unifier.title.model.ContentDetailEntry; +import java.util.UUID; +import com.backend.unifier.title.model.ContentDetailEntry; +import com.backend.unifier.title.service.GeneralDetailService; + +import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.QueryParam; @Path("/") public class DetailResource { + private final GeneralDetailService detailService; + + public DetailResource(GeneralDetailService detailService) { + this.detailService = detailService; + } + @GET @Path("/detail") - public ContentDetailEntry detail(@QueryParam("id") String id) { - return null; + public ContentDetailEntry detail(@QueryParam("id") UUID id) { + if (id == null) + throw new BadRequestException("missing 'id' parameter"); + return detailService.details(id).orElse(null); } } diff --git a/src/main/java/com/backend/unifier/title/service/DetailService.java b/src/main/java/com/backend/unifier/title/service/DetailService.java new file mode 100644 index 0000000..1610179 --- /dev/null +++ b/src/main/java/com/backend/unifier/title/service/DetailService.java @@ -0,0 +1,9 @@ +package com.backend.unifier.title.service; + +import java.util.Optional; + +import com.backend.unifier.title.model.ContentDetailEntry; + +public interface DetailService extends ContentProvider { + Optional details(String id); +} diff --git a/src/main/java/com/backend/unifier/title/service/GeneralDetailService.java b/src/main/java/com/backend/unifier/title/service/GeneralDetailService.java new file mode 100644 index 0000000..52fd2e0 --- /dev/null +++ b/src/main/java/com/backend/unifier/title/service/GeneralDetailService.java @@ -0,0 +1,34 @@ +package com.backend.unifier.title.service; + +import java.util.Optional; +import java.util.UUID; + +import com.backend.unifier.title.model.ContentDetailEntry; +import com.backend.unifier.title.model.ContentIdentifier; +import com.backend.unifier.title.model.ContentProviderSource; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; + +@ApplicationScoped +public class GeneralDetailService { + private final IdentifierMaskService maskService; + private final Instance detailServices; + + public GeneralDetailService(IdentifierMaskService maskService, Instance detailServices) { + this.maskService = maskService; + this.detailServices = detailServices; + } + + public Optional details(UUID id) { + ContentIdentifier realIdentifier = maskService.realIdentifier(id); + if (realIdentifier == null) + throw new IllegalStateException("content identifier not found for id '" + id + "'"); + ContentProviderSource source = ContentProviderSource.valueOf(realIdentifier.source()); + for (DetailService detailService : detailServices) { + if (detailService.source() == source) + return detailService.details(realIdentifier.id()); + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/backend/unifier/title/service/kodik/KodikDetailService.java b/src/main/java/com/backend/unifier/title/service/kodik/KodikDetailService.java new file mode 100644 index 0000000..1d2d692 --- /dev/null +++ b/src/main/java/com/backend/unifier/title/service/kodik/KodikDetailService.java @@ -0,0 +1,47 @@ +package com.backend.unifier.title.service.kodik; + +import java.util.Optional; + +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import com.backend.unifier.title.api.KodikAPI; +import com.backend.unifier.title.api.ShikimoriAPI; +import com.backend.unifier.title.mapper.KodikAndShikimoriDetailMapper; +import com.backend.unifier.title.model.ContentDetailEntry; +import com.backend.unifier.title.model.ContentProviderSource; +import com.backend.unifier.title.service.DetailService; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class KodikDetailService implements DetailService { + private final KodikAPI kodikAPI; + private final ShikimoriAPI shikimoriAPI; + private final KodikAndShikimoriDetailMapper detailMapper; + + public KodikDetailService(@RestClient KodikAPI kodikAPI, @RestClient ShikimoriAPI shikimoriAPI, + KodikAndShikimoriDetailMapper detailMapper) { + this.kodikAPI = kodikAPI; + this.shikimoriAPI = shikimoriAPI; + this.detailMapper = detailMapper; + } + + @Override + public ContentProviderSource source() { + return ContentProviderSource.KODIK; + } + + @Override + public Optional details(String id) { + var kodikResponse = kodikAPI.findByKodikId(id); + int totalSize = kodikResponse.total; + if (kodikResponse.results != null) + totalSize = kodikResponse.results.size(); + if (totalSize <= 0) + return Optional.empty(); + var kodikResult = kodikResponse.results.get(0); + var shikimoriResponse = shikimoriAPI.findById(kodikResult.shikimoriId); + return Optional.ofNullable(detailMapper.toDto(kodikResult, kodikResponse, shikimoriResponse)); + } + +}