Implement basic detail endpoint

This commit is contained in:
2026-03-24 04:27:51 +05:00
parent 842de8071f
commit 4f3d955879
7 changed files with 296 additions and 9 deletions

View File

@@ -4,7 +4,6 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import com.backend.metadata.kodik.api.model.KodikResponse; import com.backend.metadata.kodik.api.model.KodikResponse;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam; import jakarta.ws.rs.PathParam;
@@ -36,8 +35,4 @@ public interface KodikAPI {
@GET @GET
@Path("/imdb/{id}") @Path("/imdb/{id}")
KodikResponse findByImdbId(@PathParam("id") String id); KodikResponse findByImdbId(@PathParam("id") String id);
@GET
@Path("/search")
Uni<KodikResponse> searchAsync(@QueryParam("title") String title);
} }

View File

@@ -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<Integer, TranslationDto> 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<RelatedDto> toRelatedDtos(List<Related> 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<CharacterDto> toCharacterDtos(List<CharacterRole> roles);
default String firstOf(List<String> list) {
return (list != null && !list.isEmpty()) ? list.get(0) : null;
}
}

View File

@@ -1,4 +1,45 @@
package com.backend.unifier.title.model; 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<TranslationDto> translations;
public List<String> genres;
public String origin; // manga, novel, original …
public String rating; // pg_13, r_plus, r …
public List<String> producers;
public List<RelatedDto> related;
public List<CharacterDto> characters;
public List<String> studios;
public List<String> 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) {
}
} }

View File

@@ -1,16 +1,28 @@
package com.backend.unifier.title.resource; 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.GET;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
@Path("/") @Path("/")
public class DetailResource { public class DetailResource {
private final GeneralDetailService detailService;
public DetailResource(GeneralDetailService detailService) {
this.detailService = detailService;
}
@GET @GET
@Path("/detail") @Path("/detail")
public ContentDetailEntry detail(@QueryParam("id") String id) { public ContentDetailEntry detail(@QueryParam("id") UUID id) {
return null; if (id == null)
throw new BadRequestException("missing 'id' parameter");
return detailService.details(id).orElse(null);
} }
} }

View File

@@ -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<ContentDetailEntry> details(String id);
}

View File

@@ -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<DetailService> detailServices;
public GeneralDetailService(IdentifierMaskService maskService, Instance<DetailService> detailServices) {
this.maskService = maskService;
this.detailServices = detailServices;
}
public Optional<ContentDetailEntry> 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();
}
}

View File

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