Integrate PosterUrlValidator, record-builder

This commit is contained in:
2026-03-06 16:39:09 +05:00
parent 377ea90bfe
commit 9cf368291b
9 changed files with 207 additions and 35 deletions

View File

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

View File

@@ -1,5 +1,6 @@
package com.backend.unifier.title.service.mapper;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@@ -10,17 +11,17 @@ import org.mapstruct.Named;
import com.backend.metadata.kodik.service.api.model.KodikResponse;
import com.backend.metadata.kodik.service.api.model.MaterialData;
import com.backend.unifier.title.service.model.SearchEntryDTO;
import com.backend.unifier.title.service.model.SearchResponseDTO;
@Mapper
public interface KodikResponseMapper {
default SearchResponseDTO toSearchResponseDTO(KodikResponse kodikResponse) {
if (kodikResponse == null || kodikResponse.results == null) {
return new SearchResponseDTO(List.of());
}
List<SearchResponseDTO.SearchEntryDTO> entries = kodikResponse.results.stream()
List<SearchEntryDTO> entries = kodikResponse.results.stream()
.map(this::toSearchEntryDTO)
.filter(Objects::nonNull)
.collect(Collectors.toList());
@@ -28,7 +29,8 @@ public interface KodikResponseMapper {
return new SearchResponseDTO(entries);
}
@Mapping(target = "posterURL", source = "materialData", qualifiedByName = "extractPosterUrl")
@Mapping(target = "id", source = "result", qualifiedByName = "extractID")
@Mapping(target = "posterURLs", source = "materialData", qualifiedByName = "extractPosterUrls")
@Mapping(target = "title", source = "result", qualifiedByName = "extractTitle")
@Mapping(target = "episodeCount", source = "episodesCount")
@Mapping(target = "rating", source = "materialData", qualifiedByName = "extractRating")
@@ -37,17 +39,29 @@ public interface KodikResponseMapper {
@Mapping(target = "studio", source = "materialData", qualifiedByName = "extractStudio")
@Mapping(target = "genres", source = "materialData", qualifiedByName = "extractGenres")
@Mapping(target = "durationMin", source = "materialData", qualifiedByName = "extractDurationMin")
SearchResponseDTO.SearchEntryDTO toSearchEntryDTO(KodikResponse.Result result);
SearchEntryDTO toSearchEntryDTO(KodikResponse.Result result);
@Named("extractPosterUrl")
default String extractPosterUrl(MaterialData materialData) {
@Named("extractPosterUrls")
default List<String> extractPosterUrls(MaterialData materialData) {
List<String> result = new ArrayList<>();
if (materialData == null)
return "";
return result;
if (materialData.animePosterUrl != null && !materialData.animePosterUrl.isEmpty()) {
return materialData.animePosterUrl;
result.add(materialData.animePosterUrl);
}
return materialData.posterUrl != null ? materialData.posterUrl : "";
if (materialData.posterUrl != null && !materialData.posterUrl.isEmpty()) {
result.add(materialData.posterUrl);
}
return result;
}
@Named("extractID")
default String extractID(KodikResponse.Result result) {
if (result == null)
return "";
return result.id;
}
@Named("extractTitle")

View File

@@ -0,0 +1,19 @@
package com.backend.unifier.title.service.model;
import java.util.List;
import io.soabase.recordbuilder.core.RecordBuilder;
@RecordBuilder
public record SearchEntryDTO(
String id,
List<String> posterURLs,
String title,
int episodeCount,
double rating,
String ratingSource,
String type,
String studio,
List<String> genres,
int durationMin) implements SearchEntryDTOBuilder.With {
}

View File

@@ -2,16 +2,9 @@ package com.backend.unifier.title.service.model;
import java.util.List;
public record SearchResponseDTO(List<SearchEntryDTO> result) {
public record SearchEntryDTO(
String posterURL,
String title,
int episodeCount,
double rating, String ratingSource,
String type,
String studio,
List<String> genres,
int durationMin) {
import io.soabase.recordbuilder.core.RecordBuilder;
@RecordBuilder
public record SearchResponseDTO(List<SearchEntryDTO> result) implements SearchResponseDTOBuilder.With {
}
}

View File

@@ -1,30 +1,32 @@
package com.backend.unifier.title.service.resource;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.mapstruct.factory.Mappers;
import com.backend.unifier.title.service.api.KodikSearchService;
import com.backend.unifier.title.service.mapper.KodikResponseMapper;
import com.backend.unifier.title.service.model.SearchResponseDTO;
import com.backend.unifier.title.service.service.KodikResponseConvertService;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
@Path("/search")
public class SearchResource {
KodikResponseMapper responseMapper = Mappers.getMapper(KodikResponseMapper.class);
private final KodikSearchService kodikSearchService;
private final KodikResponseConvertService kodikConvertService;
public SearchResource(@RestClient KodikSearchService kodikSearchService) {
public SearchResource(@RestClient KodikSearchService kodikSearchService,
KodikResponseConvertService kodikConvertService) {
this.kodikSearchService = kodikSearchService;
this.kodikConvertService = kodikConvertService;
}
@GET
public SearchResponseDTO search(@QueryParam("title") String title) {
var kodikResponse = kodikSearchService.search(title);
return responseMapper.toSearchResponseDTO(kodikResponse);
public Uni<SearchResponseDTO> search(@QueryParam("title") String title) {
return kodikSearchService.searchAsync(title)
.onItem().ifNotNull()
.transformToUni(response -> kodikConvertService.convertAsync(response));
}
}

View File

@@ -0,0 +1,50 @@
package com.backend.unifier.title.service.service;
import java.util.List;
import java.util.stream.Collectors;
import org.mapstruct.factory.Mappers;
import com.backend.metadata.kodik.service.api.model.KodikResponse;
import com.backend.unifier.title.service.mapper.KodikResponseMapper;
import com.backend.unifier.title.service.model.SearchEntryDTO;
import com.backend.unifier.title.service.model.SearchResponseDTO;
import io.smallrye.mutiny.Uni;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class KodikResponseConvertService {
private final KodikResponseMapper responseMapper = Mappers.getMapper(KodikResponseMapper.class);
private final PosterUrlValidator posterUrlValidator;
public KodikResponseConvertService(PosterUrlValidator posterUrlValidator) {
this.posterUrlValidator = posterUrlValidator;
}
public SearchResponseDTO convert(KodikResponse response) {
return responseMapper.toSearchResponseDTO(response);
}
@SuppressWarnings("unchecked")
public Uni<SearchResponseDTO> convertAsync(KodikResponse response) {
if (response == null || response.results == null) {
return Uni.createFrom().item(new SearchResponseDTO(List.of()));
}
List<Uni<SearchEntryDTO>> entries = responseMapper.toSearchResponseDTO(response).result()
.stream()
.map(this::resolveEntryPosters)
.toList();
return Uni.combine().all().unis(entries)
.with(list -> new SearchResponseDTO((List<SearchEntryDTO>) list));
}
private Uni<SearchEntryDTO> resolveEntryPosters(SearchEntryDTO entry) {
return posterUrlValidator.resolvePosters(entry.posterURLs())
.map(entry::withPosterURLs)
.onFailure().recoverWithItem(entry);
}
}

View File

@@ -0,0 +1,73 @@
package com.backend.unifier.title.service.service;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import io.smallrye.mutiny.Uni;
import io.vertx.core.Vertx;
import io.vertx.ext.web.client.WebClient;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class PosterUrlValidator {
@ConfigProperty(name = "poster.url.timeoutMs", defaultValue = "3000")
long timeoutMs;
@Inject
Vertx vertx;
private WebClient webClient;
@PostConstruct
void init() {
webClient = WebClient.create(vertx);
}
public Uni<List<String>> resolvePosters(List<String> urls) {
if (urls == null || urls.isEmpty()) {
return Uni.createFrom().item(List.of());
}
List<String> normalized = urls.stream().map(this::normalizeDomain).toList();
return findFirstReachable(normalized, 0)
.map(firstIndex -> reorderToFront(normalized, firstIndex));
}
private Uni<Integer> findFirstReachable(List<String> urls, int index) {
if (index >= urls.size()) {
return Uni.createFrom().item(-1);
}
return isReachable(urls.get(index))
.flatMap(ok -> ok ? Uni.createFrom().item(index) : findFirstReachable(urls, index + 1));
}
private List<String> reorderToFront(List<String> urls, int firstIndex) {
if (firstIndex <= 0)
return urls;
List<String> reordered = new ArrayList<>(urls);
reordered.add(0, reordered.remove(firstIndex));
return reordered;
}
private String normalizeDomain(String url) {
if (url == null)
return null;
return url.contains("shikimori") ? url.replaceFirst("https?://[^/]*shikimori[^/]*/", "https://shikimori.io/")
: url;
}
private Uni<Boolean> isReachable(String url) {
if (url == null || url.isEmpty()) {
return Uni.createFrom().item(false);
}
return Uni.createFrom().completionStage(
webClient.headAbs(url).timeout(timeoutMs).send().toCompletionStage())
.map(response -> response.statusCode() >= 200 && response.statusCode() < 400)
.onFailure().recoverWithItem(false);
}
}