Enhance Comicvine metadata parsing and error handling (#835)

This commit is contained in:
Aditya Chandel
2025-08-06 14:23:51 -06:00
committed by GitHub
parent 0a6abd9dd4
commit ac798d05b5
16 changed files with 211 additions and 298 deletions

View File

@@ -6,7 +6,10 @@ import com.adityachandel.booklore.model.entity.AuthorEntity;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.CategoryEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import org.mapstruct.*;
import org.mapstruct.Context;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import java.util.Set;
import java.util.stream.Collectors;

View File

@@ -5,7 +5,10 @@ import com.adityachandel.booklore.model.dto.FetchedProposal;
import com.adityachandel.booklore.model.entity.MetadataFetchProposalEntity;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.mapstruct.*;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.springframework.beans.factory.annotation.Autowired;
@Mapper(componentModel = "spring")

View File

@@ -5,8 +5,6 @@ import com.adityachandel.booklore.model.entity.LibraryEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.util.List;
@Mapper(componentModel = "spring")
public interface LibraryMapper {

View File

@@ -1,13 +1,8 @@
package com.adityachandel.booklore.mapper;
import com.adityachandel.booklore.model.dto.LibraryPath;
import com.adityachandel.booklore.model.dto.Shelf;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.util.List;
@Mapper(componentModel = "spring")
public interface LibraryPathMapper {

View File

@@ -1,9 +1,7 @@
package com.adityachandel.booklore.mapper;
import com.adityachandel.booklore.model.dto.OpdsUser;
import com.adityachandel.booklore.model.dto.Shelf;
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
import com.adityachandel.booklore.model.entity.ShelfEntity;
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring")

View File

@@ -13,7 +13,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j

View File

@@ -3,12 +3,10 @@ package com.adityachandel.booklore.mapper.v2;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.dto.LibraryPath;
import com.adityachandel.booklore.model.entity.AuthorEntity;
import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
import com.adityachandel.booklore.model.entity.CategoryEntity;
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
import org.mapstruct.*;
import com.adityachandel.booklore.model.entity.*;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import java.util.Set;
import java.util.stream.Collectors;

View File

@@ -1,127 +1,83 @@
package com.adityachandel.booklore.model.dto.response.comicvineapi;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "resource_type",
defaultImpl = Volume.class
)
public class Comic {
@JsonSubTypes({
@JsonSubTypes.Type(value = Volume.class, name = "volume"),
@JsonSubTypes.Type(value = Issue.class, name = "issue")
})
private int id;
public abstract class Comic {
private int id;
protected String name;
protected ComicVineImage image;
private String description;
@JsonProperty("api_detail_url")
private String apiDetailUrl;
@JsonProperty("concept_credits")
private List<ComicvineItem> conceptCredits;
@JsonProperty("cover_date")
private String coverDate;
private String description;
@JsonProperty("person_credits")
private List<ComicvineItem> personCredits;
private String name;
@JsonProperty("issue_number")
private String issueNumber;
private Image image;
private Volume volume;
@JsonProperty("resource_type")
private String resourceType;
public abstract String getDisplayName();
public abstract String getComicId();
public abstract LocalDate getDate();
public Set<String> getAuthors() {
Set<String> authors = new HashSet<>();
if (personCredits != null) {
for (ComicvineItem person : personCredits) {
authors.add(person.name);
}
}
return authors;
}
public String getImageUrl() {
if (image == null) {
return null;
}
return image.getOriginalUrl() != null ? image.getOriginalUrl() : image.getThumbUrl();
}
@JsonProperty("resource_type")
private String resourceType;
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ComicVineImage {
public static class Image {
@JsonProperty("icon_url")
private String iconUrl;
@JsonProperty("medium_url")
private String mediumUrl;
@JsonProperty("screen_url")
private String screenUrl;
@JsonProperty("screen_large_url")
private String screenLargeUrl;
@JsonProperty("small_url")
private String smallUrl;
@JsonProperty("super_url")
private String superUrl;
@JsonProperty("thumb_url")
private String thumbUrl;
@JsonProperty("tiny_url")
private String tinyUrl;
@JsonProperty("original_url")
private String originalUrl;
@JsonProperty("image_tags")
private String imageTags;
}
@Data
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ComicvineItem {
@JsonProperty("api_detail_url")
private String apiDetailUrl;
public static class Volume {
private int id;
private String name;
@JsonProperty("api_detail_url")
private String apiDetailUrl;
@JsonProperty("site_detail_url")
private String siteDetailUrl;
}
}
}

View File

@@ -2,16 +2,11 @@ package com.adityachandel.booklore.model.dto.response.comicvineapi;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -30,5 +25,4 @@ public class ComicvineApiResponse {
private int statusCode;
private List<Comic> results;
private String version;
}

View File

@@ -0,0 +1,35 @@
package com.adityachandel.booklore.model.dto.response.comicvineapi;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class ComicvineIssueResponse {
private String error;
private int limit;
private int offset;
private int number_of_page_results;
private int number_of_total_results;
private int status_code;
private IssueResults results;
private String version;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class IssueResults {
@JsonProperty("person_credits")
private List<PersonCredit> personCredits;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PersonCredit {
private long id;
private String name;
private String role;
}
}

View File

@@ -1,55 +0,0 @@
package com.adityachandel.booklore.model.dto.response.comicvineapi;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Issue extends Comic {
@JsonProperty("cover_date")
private String coverDate;
@JsonProperty("volume")
private ComicvineItem volume;
@JsonProperty("issue_number")
private int issueNumber;
@Override
public String getComicId() {
return "4000-" + String.valueOf(getId());
}
@Override
public String getDisplayName() {
if(name ==null){
if(volume != null){
return volume.getName() + " " + "Issue #" + String.valueOf(issueNumber);
}
else{
return "Unknown Comic";
}
}
return name;
}
@Override
public LocalDate getDate() {
if (coverDate == null || coverDate.isEmpty()) return null;
try {
return LocalDate.parse(coverDate, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
} catch (Exception e) {
return null;
}
}
}

View File

@@ -1,60 +0,0 @@
package com.adityachandel.booklore.model.dto.response.comicvineapi;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Volume extends Comic{
@JsonProperty("api_detail_url")
private String apiDetailUrl;
private String description;
@JsonProperty("publisher")
private ComicvineItem publisher;
@JsonProperty("site_detail_url")
private String siteDetailUrl;
@JsonProperty("start_year")
private String startYear;
public String getPublisherName() {
return publisher != null ? publisher.getName() : null;
}
@Override
public String getDisplayName(){
if(name==null){
return "Unknown Comic";
}
else{
return name;
}
}
@Override
public String getComicId() {
return "4500-" + String.valueOf(getId());
}
@Override
public LocalDate getDate() {
if (startYear == null || startYear.isEmpty()) return null;
try {
return LocalDate.parse(startYear, DateTimeFormatter.ofPattern("yyyy"));
} catch (Exception e) {
return null;
}
}
}

View File

@@ -3,10 +3,8 @@ package com.adityachandel.booklore.service.metadata.parser;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.BookMetadata;
import com.adityachandel.booklore.model.dto.request.FetchMetadataRequest;
import com.adityachandel.booklore.model.dto.response.comicvineapi.ComicvineApiResponse;
import com.adityachandel.booklore.model.dto.response.comicvineapi.Issue;
import com.adityachandel.booklore.model.dto.response.comicvineapi.Volume;
import com.adityachandel.booklore.model.dto.response.comicvineapi.Comic;
import com.adityachandel.booklore.model.dto.response.comicvineapi.ComicvineApiResponse;
import com.adityachandel.booklore.model.enums.MetadataProvider;
import com.adityachandel.booklore.service.appsettings.AppSettingService;
import com.adityachandel.booklore.util.BookUtils;
@@ -23,134 +21,181 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collector;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ComicvineBookParser implements BookParser {
private final ObjectMapper objectMapper;
private static final String COMICVINE_URL = "https://comicvine.gamespot.com/api/";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final ObjectMapper objectMapper;
private final AppSettingService appSettingService;
private final HttpClient httpClient = HttpClient.newHttpClient();
@Override
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
String searchTerm = getSearchTerm(book, fetchMetadataRequest);
return searchTerm != null ? getMetadataListByTerm(searchTerm) : List.of();
if (searchTerm == null) {
log.warn("No valid search term provided for metadata fetch.");
return Collections.emptyList();
}
return getMetadataListByTerm(searchTerm);
}
@Override
public BookMetadata fetchTopMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
List<BookMetadata> metadataList = fetchMetadata(book, fetchMetadataRequest);
return metadataList.isEmpty() ? null : metadataList.getFirst();
}
public List<BookMetadata> getMetadataListByTerm(String term) {
String apiToken = appSettingService.getAppSettings().getMetadataProviderSettings().getComicvine().getApiKey();
if (apiToken == null || apiToken.isEmpty()) {
log.warn("Comicvine API token not set");
return Collections.emptyList();
}
log.info("Comicvine: Fetching metadata for: {}", term);
String apiToken = getApiToken();
if (apiToken == null) return Collections.emptyList();
log.info("Comicvine: Fetching metadata for term: '{}'", term);
try {
String fieldsList = "cover_date,id,issue_number,name,person_credits,volume,api_detail_url,concept_credits,start_year,publisher,description,image";
String fieldsList = String.join(",", "api_detail_url", "cover_date", "description", "id", "image", "issue_number", "name", "publisher", "volume");
String resources = "volume,issue";
URI uri = UriComponentsBuilder.fromUriString(COMICVINE_URL) // Base URL
URI uri = UriComponentsBuilder.fromUriString(COMICVINE_URL)
.path("/search/")
.queryParam("api_key", apiToken)
.queryParam("api_key", apiToken)
.queryParam("format", "json")
.queryParam("resources", resources)
.queryParam("resource_type", resources)
.queryParam("query", term)
.queryParam("filter", "name:" + term)
.queryParam("limit", "10") // Limit results to reduce response size
.queryParam("limit", 10)
.queryParam("field_list", fieldsList)
.build()
.toUri();
log.debug("Comicvine API request URI: {}", uri);
HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("User-Agent", "MyComicApp/1.0")
.header("User-Agent", "Booklore/1.0")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return parseComicvineApiResponse(response.body());
} else {
log.error("Failed to fetch data from Comicvine API. Status code: {}", response.statusCode());
return List.of();
log.error("Comicvine Search API returned status code {}", response.statusCode());
}
} catch (IOException | InterruptedException e) {
log.error("Error fetching metadata from Comicvine API", e);
return List.of();
log.error("Error fetching metadata from Comicvine Search API", e);
}
return Collections.emptyList();
}
@Override
public BookMetadata fetchTopMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
List<BookMetadata> fetchedBookMetadata = fetchMetadata(book, fetchMetadataRequest);
return fetchedBookMetadata.isEmpty() ? null : fetchedBookMetadata.get(0);
}
private String getSearchTerm(Book book, FetchMetadataRequest request) {
return (request.getTitle() != null && !request.getTitle().isEmpty())
? request.getTitle()
: (book.getFileName() != null && !book.getFileName().isEmpty()
? BookUtils.cleanFileName(book.getFileName())
: null);
private String getSearchTerm(Book book, FetchMetadataRequest request) {
if (request.getTitle() != null && !request.getTitle().isEmpty()) {
return request.getTitle();
} else if (book.getFileName() != null && !book.getFileName().isEmpty()) {
return BookUtils.cleanFileName(book.getFileName());
}
return null;
}
private List<BookMetadata> parseComicvineApiResponse(String responseBody) throws IOException {
ComicvineApiResponse apiResponse = objectMapper.readValue(responseBody, ComicvineApiResponse.class);
if (apiResponse.getResults() == null) {
return Collections.emptyList();
}
return apiResponse.getResults().stream()
.map(this::convertToBookMetadata)
.collect(Collectors.toList());
}
private BookMetadata convertToBookMetadata(Comic comic) {
BookMetadata.BookMetadataBuilder builder = BookMetadata.builder()
.title(comic.getDisplayName())
.comicvineId(String.valueOf(comic.getId()))
.authors(comic.getAuthors())
.thumbnailUrl(comic.getImageUrl())
.description(comic.getDescription())
.provider(MetadataProvider.Comicvine);
return BookMetadata.builder()
.provider(MetadataProvider.Comicvine)
.comicvineId(String.valueOf(comic.getId()))
.title(comic.getName())
.authors(new HashSet<>())
.thumbnailUrl(comic.getImage() != null ? comic.getImage().getMediumUrl() : null)
.description(comic.getDescription())
.seriesName(comic.getVolume() != null ? comic.getVolume().getName() : null)
.seriesNumber(safeParseFloat(comic.getIssueNumber()))
.publishedDate(safeParseDate(comic.getCoverDate()))
.build();
}
// Handle publishedDate based on the comic type
if (comic instanceof Volume) {
Volume volume = (Volume) comic;
builder.publisher(volume.getPublisherName());
builder.publishedDate(volume.getDate());
} else if (comic instanceof Issue) {
Issue issue = (Issue) comic;
builder.seriesName(issue.getVolume().getName());
builder.seriesNumber((float) issue.getIssueNumber());
builder.publishedDate(issue.getDate()); // Already parsed
private static LocalDate safeParseDate(String dateStr) {
if (dateStr == null || dateStr.isEmpty()) return null;
try {
return LocalDate.parse(dateStr, DATE_FORMATTER);
} catch (DateTimeParseException e) {
log.warn("Invalid date '{}'", dateStr);
return null;
}
}
// Return the final built object
return builder.build();
private static Float safeParseFloat(String numStr) {
if (numStr == null || numStr.isEmpty()) return null;
try {
return Float.valueOf(numStr);
} catch (NumberFormatException e) {
return null;
}
}
private String getApiToken() {
String apiToken = appSettingService.getAppSettings().getMetadataProviderSettings().getComicvine().getApiKey();
if (apiToken == null || apiToken.isEmpty()) {
log.warn("Comicvine API token not set");
return null;
}
return apiToken;
}
/*public Set<String> fetchAuthors(int issueId) {
String apiToken = getApiToken();
if (apiToken == null) return Collections.emptySet();
try {
String fieldsList = String.join(",", "person_credits");
}
}
URI uri = UriComponentsBuilder.fromUriString(COMICVINE_URL)
.path("/issue/4000-" + issueId + "/")
.queryParam("api_key", apiToken)
.queryParam("format", "json")
.queryParam("field_list", fieldsList)
.build()
.toUri();
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("User-Agent", "Booklore/1.0")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
ComicvineIssueResponse issueResponse = objectMapper.readValue(response.body(), ComicvineIssueResponse.class);
if (issueResponse.getResults() == null || issueResponse.getResults().getPersonCredits() == null) {
log.warn("No person credits found for issue ID {}", issueId);
return Collections.emptySet();
}
return issueResponse.getResults().getPersonCredits().stream()
.filter(pc -> pc.getRole() != null && pc.getRole().toLowerCase().contains("writer"))
.map(ComicvineIssueResponse.PersonCredit::getName)
.collect(Collectors.toSet());
} else {
log.error("Comicvine Issue API returned status code {}", response.statusCode());
}
} catch (IOException | InterruptedException e) {
log.error("Error fetching issue metadata from Comicvine Issue API", e);
}
return Collections.emptySet();
}*/
}