Enhance metadata options to allow skipping fields during fetch and support extended field options (#1263)

This commit is contained in:
Aditya Chandel
2025-10-05 00:15:11 -06:00
committed by GitHub
parent 41f68a6075
commit ff230f34fc
9 changed files with 689 additions and 424 deletions
@@ -6,27 +6,28 @@ import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.Builder;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MetadataRefreshOptions {
private Long libraryId;
@NotNull(message = "Default Provider cannot be null")
private MetadataProvider allP1;
private MetadataProvider allP2;
private MetadataProvider allP3;
private MetadataProvider allP4;
private boolean refreshCovers;
private boolean mergeCategories;
private Boolean reviewBeforeApply;
@NotNull(message = "Field options cannot be null")
private FieldOptions fieldOptions;
@NotNull(message = "Skip fields cannot be null")
private SkipFields skipFields;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class FieldOptions {
private FieldProvider title;
private FieldProvider subtitle;
@@ -41,19 +42,68 @@ public class MetadataRefreshOptions {
private FieldProvider isbn10;
private FieldProvider language;
private FieldProvider categories;
private FieldProvider cover;
private FieldProvider pageCount;
private FieldProvider asin;
private FieldProvider goodreadsId;
private FieldProvider comicvineId;
private FieldProvider hardcoverId;
private FieldProvider googleId;
private FieldProvider amazonRating;
private FieldProvider amazonReviewCount;
private FieldProvider goodreadsRating;
private FieldProvider goodreadsReviewCount;
private FieldProvider hardcoverRating;
private FieldProvider hardcoverReviewCount;
private FieldProvider moods;
private FieldProvider tags;
private FieldProvider cover;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class FieldProvider {
private MetadataProvider p4;
private MetadataProvider p3;
private MetadataProvider p2;
private MetadataProvider p1;
private MetadataProvider p2;
private MetadataProvider p3;
private MetadataProvider p4;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class SkipFields {
private boolean title;
private boolean subtitle;
private boolean description;
private boolean authors;
private boolean publisher;
private boolean publishedDate;
private boolean seriesName;
private boolean seriesNumber;
private boolean seriesTotal;
private boolean isbn13;
private boolean isbn10;
private boolean language;
private boolean categories;
private boolean cover;
private boolean pageCount;
private boolean asin;
private boolean goodreadsId;
private boolean comicvineId;
private boolean hardcoverId;
private boolean googleId;
private boolean amazonRating;
private boolean amazonReviewCount;
private boolean goodreadsRating;
private boolean goodreadsReviewCount;
private boolean hardcoverRating;
private boolean hardcoverReviewCount;
private boolean moods;
private boolean tags;
}
}
@@ -110,69 +110,83 @@ public class SettingPersistenceHelper {
}
MetadataRefreshOptions getDefaultMetadataRefreshOptions() {
MetadataRefreshOptions.FieldProvider titleProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider subtitleProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider descriptionProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider authorsProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider publisherProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider publishedDateProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider seriesNameProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider seriesNumberProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider seriesTotalProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider isbn13Providers =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider isbn10Providers =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider languageProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider categoriesProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider moodsProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider tagsProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider coverProviders =
new MetadataRefreshOptions.FieldProvider(null, MetadataProvider.Google, MetadataProvider.Amazon, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider amazonProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Amazon)
.build();
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
titleProviders,
subtitleProviders,
descriptionProviders,
authorsProviders,
publisherProviders,
publishedDateProviders,
seriesNameProviders,
seriesNumberProviders,
seriesTotalProviders,
isbn13Providers,
isbn10Providers,
languageProviders,
categoriesProviders,
moodsProviders,
tagsProviders,
coverProviders
);
MetadataRefreshOptions.FieldProvider nullProvider = MetadataRefreshOptions.FieldProvider.builder()
.build();
return new MetadataRefreshOptions(
null,
MetadataProvider.GoodReads,
MetadataProvider.Amazon,
MetadataProvider.Google,
null,
false,
true,
false,
fieldOptions
);
MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
.title(amazonProvider)
.subtitle(amazonProvider)
.description(amazonProvider)
.authors(amazonProvider)
.publisher(amazonProvider)
.publishedDate(amazonProvider)
.seriesName(amazonProvider)
.seriesNumber(amazonProvider)
.seriesTotal(amazonProvider)
.isbn13(amazonProvider)
.isbn10(amazonProvider)
.language(amazonProvider)
.categories(amazonProvider)
.cover(amazonProvider)
.pageCount(amazonProvider)
.asin(nullProvider)
.goodreadsId(nullProvider)
.comicvineId(nullProvider)
.hardcoverId(nullProvider)
.googleId(nullProvider)
.amazonRating(nullProvider)
.amazonReviewCount(nullProvider)
.goodreadsRating(nullProvider)
.goodreadsReviewCount(nullProvider)
.hardcoverRating(nullProvider)
.hardcoverReviewCount(nullProvider)
.moods(nullProvider)
.tags(nullProvider)
.build();
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder()
.title(false)
.subtitle(false)
.description(false)
.authors(false)
.publisher(false)
.publishedDate(false)
.seriesName(false)
.seriesNumber(false)
.seriesTotal(false)
.isbn13(false)
.isbn10(false)
.language(false)
.categories(false)
.cover(false)
.pageCount(false)
.asin(false)
.goodreadsId(false)
.comicvineId(false)
.hardcoverId(false)
.googleId(false)
.amazonRating(false)
.amazonReviewCount(false)
.goodreadsRating(false)
.goodreadsReviewCount(false)
.hardcoverRating(false)
.hardcoverReviewCount(false)
.moods(false)
.tags(false)
.build();
return MetadataRefreshOptions.builder()
.libraryId(null)
.refreshCovers(false)
.mergeCategories(true)
.reviewBeforeApply(false)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.build();
}
public MetadataMatchWeights getDefaultMetadataMatchWeights() {
@@ -32,6 +32,9 @@ import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import static com.adityachandel.booklore.model.enums.MetadataProvider.*;
@@ -213,7 +216,7 @@ public class MetadataRefreshService {
));
}
private CompletableFuture<BookMetadata> createInterruptibleMetadataFuture(java.util.function.Supplier<BookMetadata> metadataSupplier) {
private CompletableFuture<BookMetadata> createInterruptibleMetadataFuture(Supplier<BookMetadata> metadataSupplier) {
return CompletableFuture.supplyAsync(() -> {
if (Thread.currentThread().isInterrupted()) {
log.info("Skipping metadata fetch due to interruption");
@@ -323,12 +326,33 @@ public class MetadataRefreshService {
if (fieldOptions != null) {
addProviderToSet(fieldOptions.getTitle(), uniqueProviders);
addProviderToSet(fieldOptions.getSubtitle(), uniqueProviders);
addProviderToSet(fieldOptions.getDescription(), uniqueProviders);
addProviderToSet(fieldOptions.getAuthors(), uniqueProviders);
addProviderToSet(fieldOptions.getPublisher(), uniqueProviders);
addProviderToSet(fieldOptions.getPublishedDate(), uniqueProviders);
addProviderToSet(fieldOptions.getSeriesName(), uniqueProviders);
addProviderToSet(fieldOptions.getSeriesNumber(), uniqueProviders);
addProviderToSet(fieldOptions.getSeriesTotal(), uniqueProviders);
addProviderToSet(fieldOptions.getIsbn13(), uniqueProviders);
addProviderToSet(fieldOptions.getIsbn10(), uniqueProviders);
addProviderToSet(fieldOptions.getLanguage(), uniqueProviders);
addProviderToSet(fieldOptions.getCategories(), uniqueProviders);
addProviderToSet(fieldOptions.getCover(), uniqueProviders);
addProviderToSet(fieldOptions.getPageCount(), uniqueProviders);
addProviderToSet(fieldOptions.getAsin(), uniqueProviders);
addProviderToSet(fieldOptions.getGoodreadsId(), uniqueProviders);
addProviderToSet(fieldOptions.getComicvineId(), uniqueProviders);
addProviderToSet(fieldOptions.getHardcoverId(), uniqueProviders);
addProviderToSet(fieldOptions.getGoogleId(), uniqueProviders);
addProviderToSet(fieldOptions.getAmazonRating(), uniqueProviders);
addProviderToSet(fieldOptions.getAmazonReviewCount(), uniqueProviders);
addProviderToSet(fieldOptions.getGoodreadsRating(), uniqueProviders);
addProviderToSet(fieldOptions.getGoodreadsReviewCount(), uniqueProviders);
addProviderToSet(fieldOptions.getHardcoverRating(), uniqueProviders);
addProviderToSet(fieldOptions.getHardcoverReviewCount(), uniqueProviders);
addProviderToSet(fieldOptions.getMoods(), uniqueProviders);
addProviderToSet(fieldOptions.getTags(), uniqueProviders);
addProviderToSet(fieldOptions.getCover(), uniqueProviders);
}
return uniqueProviders;
@@ -343,7 +367,6 @@ public class MetadataRefreshService {
}
}
public BookMetadata fetchTopMetadataFromAProvider(MetadataProvider provider, Book book) {
return getParser(provider).fetchTopMetadata(book, buildFetchMetadataRequestFromBook(book));
}
@@ -370,10 +393,122 @@ public class MetadataRefreshService {
public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshOptions refreshOptions, Map<MetadataProvider, BookMetadata> metadataMap) {
BookMetadata metadata = BookMetadata.builder().bookId(bookId).build();
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
MetadataRefreshOptions.SkipFields skipFields = refreshOptions.getSkipFields();
metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle));
metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription));
metadata.setAuthors(resolveFieldAsList(metadataMap, fieldOptions.getAuthors(), BookMetadata::getAuthors));
if (!skipFields.isTitle()) {
metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle));
}
if (!skipFields.isSubtitle()) {
metadata.setSubtitle(resolveFieldAsString(metadataMap, fieldOptions.getSubtitle(), BookMetadata::getSubtitle));
}
if (!skipFields.isDescription()) {
metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription));
}
if (!skipFields.isAuthors()) {
metadata.setAuthors(resolveFieldAsList(metadataMap, fieldOptions.getAuthors(), BookMetadata::getAuthors));
}
if (!skipFields.isPublisher()) {
metadata.setPublisher(resolveFieldAsString(metadataMap, fieldOptions.getPublisher(), BookMetadata::getPublisher));
}
if (!skipFields.isPublishedDate()) {
metadata.setPublishedDate(resolveField(metadataMap, fieldOptions.getPublishedDate(), BookMetadata::getPublishedDate));
}
if (!skipFields.isSeriesName()) {
metadata.setSeriesName(resolveFieldAsString(metadataMap, fieldOptions.getSeriesName(), BookMetadata::getSeriesName));
}
if (!skipFields.isSeriesNumber()) {
metadata.setSeriesNumber(resolveField(metadataMap, fieldOptions.getSeriesNumber(), BookMetadata::getSeriesNumber));
}
if (!skipFields.isSeriesTotal()) {
metadata.setSeriesTotal(resolveFieldAsInteger(metadataMap, fieldOptions.getSeriesTotal(), BookMetadata::getSeriesTotal));
}
if (!skipFields.isIsbn13()) {
metadata.setIsbn13(resolveFieldAsString(metadataMap, fieldOptions.getIsbn13(), BookMetadata::getIsbn13));
}
if (!skipFields.isIsbn10()) {
metadata.setIsbn10(resolveFieldAsString(metadataMap, fieldOptions.getIsbn10(), BookMetadata::getIsbn10));
}
if (!skipFields.isLanguage()) {
metadata.setLanguage(resolveFieldAsString(metadataMap, fieldOptions.getLanguage(), BookMetadata::getLanguage));
}
if (!skipFields.isPageCount()) {
metadata.setPageCount(resolveFieldAsInteger(metadataMap, fieldOptions.getPageCount(), BookMetadata::getPageCount));
}
if (!skipFields.isCover()) {
metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl));
}
if (!skipFields.isAmazonRating()) {
if (metadataMap.containsKey(Amazon)) {
metadata.setAmazonRating(metadataMap.get(Amazon).getAmazonRating());
}
}
if (!skipFields.isAmazonReviewCount()) {
if (metadataMap.containsKey(Amazon)) {
metadata.setAmazonReviewCount(metadataMap.get(Amazon).getAmazonReviewCount());
}
}
if (!skipFields.isGoodreadsRating()) {
if (metadataMap.containsKey(GoodReads)) {
metadata.setGoodreadsRating(metadataMap.get(GoodReads).getGoodreadsRating());
}
}
if (!skipFields.isGoodreadsReviewCount()) {
if (metadataMap.containsKey(GoodReads)) {
metadata.setGoodreadsReviewCount(metadataMap.get(GoodReads).getGoodreadsReviewCount());
}
}
if (!skipFields.isHardcoverRating()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setHardcoverRating(metadataMap.get(Hardcover).getHardcoverRating());
}
}
if (!skipFields.isHardcoverReviewCount()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setHardcoverReviewCount(metadataMap.get(Hardcover).getHardcoverReviewCount());
}
}
if (!skipFields.isAsin()) {
if (metadataMap.containsKey(Amazon)) {
metadata.setAsin(metadataMap.get(Amazon).getAsin());
}
}
if (!skipFields.isGoodreadsId()) {
if (metadataMap.containsKey(GoodReads)) {
metadata.setGoodreadsId(metadataMap.get(GoodReads).getGoodreadsId());
}
}
if (!skipFields.isHardcoverId()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setHardcoverId(metadataMap.get(Hardcover).getHardcoverId());
}
}
if (!skipFields.isGoogleId()) {
if (metadataMap.containsKey(Google)) {
metadata.setGoogleId(metadataMap.get(Google).getGoogleId());
}
}
if (!skipFields.isComicvineId()) {
if (metadataMap.containsKey(Comicvine)) {
metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId());
}
}
if (!skipFields.isMoods()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setMoods(metadataMap.get(Hardcover).getMoods());
}
}
if (!skipFields.isTags()) {
if (metadataMap.containsKey(Hardcover)) {
metadata.setTags(metadataMap.get(Hardcover).getTags());
}
}
if (!skipFields.isCategories()) {
if (refreshOptions.isMergeCategories()) {
metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
} else {
metadata.setCategories(resolveFieldAsList(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
}
}
List<BookReview> allReviews = metadataMap.values().stream()
.filter(Objects::nonNull)
@@ -383,187 +518,70 @@ public class MetadataRefreshService {
metadata.setBookReviews(allReviews);
}
if (metadataMap.containsKey(GoodReads)) {
metadata.setGoodreadsId(metadataMap.get(GoodReads).getGoodreadsId());
}
if (metadataMap.containsKey(Hardcover)) {
metadata.setHardcoverId(metadataMap.get(Hardcover).getHardcoverId());
}
if (metadataMap.containsKey(Google)) {
metadata.setGoogleId(metadataMap.get(Google).getGoogleId());
}
if (metadataMap.containsKey(Comicvine)) {
metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId());
}
if (refreshOptions.isMergeCategories()) {
metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
metadata.setMoods(getAllMoods(metadataMap, fieldOptions.getMoods(), BookMetadata::getMoods));
metadata.setTags(getAllTags(metadataMap, fieldOptions.getTags(), BookMetadata::getTags));
} else {
metadata.setCategories(resolveFieldAsList(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
metadata.setMoods(resolveFieldAsList(metadataMap, fieldOptions.getMoods(), BookMetadata::getMoods));
metadata.setTags(resolveFieldAsList(metadataMap, fieldOptions.getTags(), BookMetadata::getTags));
}
metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl));
if (refreshOptions.getAllP4() != null) {
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP4());
}
if (refreshOptions.getAllP3() != null) {
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP3());
}
if (refreshOptions.getAllP2() != null) {
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP2());
}
if (refreshOptions.getAllP1() != null) {
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP1());
}
return metadata;
}
protected void setOtherUnspecifiedMetadata(Map<MetadataProvider, BookMetadata> metadataMap, BookMetadata metadataCombined, MetadataProvider provider) {
if (metadataMap.containsKey(provider)) {
BookMetadata metadata = metadataMap.get(provider);
metadataCombined.setSubtitle(metadata.getSubtitle() != null ? metadata.getSubtitle() : metadataCombined.getSubtitle());
metadataCombined.setPublisher(metadata.getPublisher() != null ? metadata.getPublisher() : metadataCombined.getPublisher());
metadataCombined.setPublishedDate(metadata.getPublishedDate() != null ? metadata.getPublishedDate() : metadataCombined.getPublishedDate());
metadataCombined.setIsbn10(metadata.getIsbn10() != null ? metadata.getIsbn10() : metadataCombined.getIsbn10());
metadataCombined.setIsbn13(metadata.getIsbn13() != null ? metadata.getIsbn13() : metadataCombined.getIsbn13());
metadataCombined.setAsin(metadata.getAsin() != null ? metadata.getAsin() : metadataCombined.getAsin());
metadataCombined.setPageCount(metadata.getPageCount() != null ? metadata.getPageCount() : metadataCombined.getPageCount());
metadataCombined.setLanguage(metadata.getLanguage() != null ? metadata.getLanguage() : metadataCombined.getLanguage());
metadataCombined.setGoodreadsRating(metadata.getGoodreadsRating() != null ? metadata.getGoodreadsRating() : metadataCombined.getGoodreadsRating());
metadataCombined.setGoodreadsReviewCount(metadata.getGoodreadsReviewCount() != null ? metadata.getGoodreadsReviewCount() : metadataCombined.getGoodreadsReviewCount());
metadataCombined.setAmazonRating(metadata.getAmazonRating() != null ? metadata.getAmazonRating() : metadataCombined.getAmazonRating());
metadataCombined.setAmazonReviewCount(metadata.getAmazonReviewCount() != null ? metadata.getAmazonReviewCount() : metadataCombined.getAmazonReviewCount());
metadataCombined.setHardcoverRating(metadata.getHardcoverRating() != null ? metadata.getHardcoverRating() : metadataCombined.getHardcoverRating());
metadataCombined.setHardcoverReviewCount(metadata.getHardcoverReviewCount() != null ? metadata.getHardcoverReviewCount() : metadataCombined.getHardcoverReviewCount());
metadataCombined.setPersonalRating(metadata.getPersonalRating() != null ? metadata.getPersonalRating() : metadataCombined.getPersonalRating());
metadataCombined.setSeriesName(metadata.getSeriesName() != null ? metadata.getSeriesName() : metadataCombined.getSeriesName());
metadataCombined.setSeriesNumber(metadata.getSeriesNumber() != null ? metadata.getSeriesNumber() : metadataCombined.getSeriesNumber());
metadataCombined.setSeriesTotal(metadata.getSeriesTotal() != null ? metadata.getSeriesTotal() : metadataCombined.getSeriesTotal());
}
protected <T> T resolveField(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function<BookMetadata, T> extractor) {
return resolveFieldWithProviders(metadataMap, fieldProvider, extractor, (value) -> value != null);
}
@FunctionalInterface
public interface FieldValueExtractor {
String extract(BookMetadata metadata);
protected Integer resolveFieldAsInteger(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function<BookMetadata, Integer> fieldValueExtractor) {
return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor, (value) -> value != null);
}
@FunctionalInterface
public interface FieldValueExtractorList {
Set<String> extract(BookMetadata metadata);
}
protected String resolveFieldAsString(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractor fieldValueExtractor) {
String value = null;
if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4()));
if (newValue != null) value = newValue;
}
if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) {
String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3()));
if (newValue != null) value = newValue;
}
if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2()));
if (newValue != null) value = newValue;
}
if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
String newValue = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1()));
if (newValue != null) value = newValue;
}
return value;
return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, (value) -> value != null);
}
protected Set<String> resolveFieldAsList(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) {
Set<String> values = new HashSet<>();
if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
Set<String> newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4()));
if (newValues != null && !newValues.isEmpty()) values = newValues;
return resolveFieldWithProviders(metadataMap, fieldProvider, fieldValueExtractor::extract, (value) -> value != null && !value.isEmpty());
}
private <T> T resolveFieldWithProviders(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, Function<BookMetadata, T> extractor, Predicate<T> isValidValue) {
if (fieldProvider == null) {
return null;
}
if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) {
Set<String> newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3()));
if (newValues != null && !newValues.isEmpty()) values = newValues;
MetadataProvider[] providers = {
fieldProvider.getP4(),
fieldProvider.getP3(),
fieldProvider.getP2(),
fieldProvider.getP1()
};
for (MetadataProvider provider : providers) {
if (provider != null && metadataMap.containsKey(provider)) {
T value = extractor.apply(metadataMap.get(provider));
if (isValidValue.test(value)) {
return value;
}
}
}
if (values.isEmpty() && fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
Set<String> newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2()));
if (newValues != null && !newValues.isEmpty()) values = newValues;
}
if (values.isEmpty() && fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
Set<String> newValues = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1()));
if (newValues != null && !newValues.isEmpty()) values = newValues;
}
return values;
return null;
}
Set<String> getAllCategories(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) {
Set<String> uniqueCategories = new HashSet<>();
if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4()));
if (extracted != null) uniqueCategories.addAll(extracted);
if (fieldProvider == null) {
return uniqueCategories;
}
if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3()));
if (extracted != null) uniqueCategories.addAll(extracted);
}
if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2()));
if (extracted != null) uniqueCategories.addAll(extracted);
}
if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1()));
if (extracted != null) uniqueCategories.addAll(extracted);
}
return new HashSet<>(uniqueCategories);
}
Set<String> getAllMoods(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) {
Set<String> uniqueMoods = new HashSet<>();
if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4()));
if (extracted != null) uniqueMoods.addAll(extracted);
}
if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3()));
if (extracted != null) uniqueMoods.addAll(extracted);
}
if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2()));
if (extracted != null) uniqueMoods.addAll(extracted);
}
if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1()));
if (extracted != null) uniqueMoods.addAll(extracted);
}
return new HashSet<>(uniqueMoods);
}
MetadataProvider[] providers = {
fieldProvider.getP4(),
fieldProvider.getP3(),
fieldProvider.getP2(),
fieldProvider.getP1()
};
Set<String> getAllTags(Map<MetadataProvider, BookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider, FieldValueExtractorList fieldValueExtractor) {
Set<String> uniqueTags = new HashSet<>();
if (fieldProvider.getP4() != null && metadataMap.containsKey(fieldProvider.getP4())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP4()));
if (extracted != null) uniqueTags.addAll(extracted);
for (MetadataProvider provider : providers) {
if (provider != null && metadataMap.containsKey(provider)) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(provider));
if (extracted != null) {
uniqueCategories.addAll(extracted);
}
}
}
if (fieldProvider.getP3() != null && metadataMap.containsKey(fieldProvider.getP3())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP3()));
if (extracted != null) uniqueTags.addAll(extracted);
}
if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP2()));
if (extracted != null) uniqueTags.addAll(extracted);
}
if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
Set<String> extracted = fieldValueExtractor.extract(metadataMap.get(fieldProvider.getP1()));
if (extracted != null) uniqueTags.addAll(extracted);
}
return new HashSet<>(uniqueTags);
}
return uniqueCategories;
}
protected Set<Long> getBookEntities(MetadataRefreshRequest request) {
MetadataRefreshRequest.RefreshType refreshType = request.getRefreshType();
@@ -578,4 +596,4 @@ public class MetadataRefreshService {
case BOOKS -> request.getBookIds();
};
}
}
}
@@ -83,41 +83,69 @@ class MetadataRefreshServiceTest {
}
private void setupDefaultOptions() {
MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
null, null, MetadataProvider.Google, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider descriptionProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider authorsProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider moodProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider tagProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
.p3(MetadataProvider.Google)
.p1(MetadataProvider.GoodReads)
.build();
MetadataRefreshOptions.FieldProvider descriptionProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldProvider authorsProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.GoodReads)
.build();
MetadataRefreshOptions.FieldProvider categoriesProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldProvider moodProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldProvider tagProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldProvider coverProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.GoodReads)
.build();
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
titleProvider, null, descriptionProvider, authorsProvider, null, null,
null, null, null, null, null, null, categoriesProvider, moodProvider, tagProvider, coverProvider);
MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
.title(titleProvider)
.description(descriptionProvider)
.authors(authorsProvider)
.categories(categoriesProvider)
.moods(moodProvider)
.tags(tagProvider)
.cover(coverProvider)
.build();
defaultOptions = new MetadataRefreshOptions(
null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
true, false, false, fieldOptions);
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
defaultOptions = MetadataRefreshOptions.builder()
.refreshCovers(true)
.mergeCategories(false)
.reviewBeforeApply(false)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.build();
}
private void setupLibraryOptions() {
MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
titleProvider, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null);
MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
.title(titleProvider)
.build();
libraryOptions = new MetadataRefreshOptions(
1L, MetadataProvider.Google, null, null, null,
false, true, true, fieldOptions);
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
libraryOptions = MetadataRefreshOptions.builder()
.libraryId(1L)
.refreshCovers(false)
.mergeCategories(true)
.reviewBeforeApply(true)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.build();
}
private void setupAppSettings() {
@@ -225,15 +253,21 @@ class MetadataRefreshServiceTest {
@Test
void testRefreshMetadata_WithRequestOptions_ShouldUseRequestOptions() {
// Given
MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Hardcover);
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
titleProvider, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null);
MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Hardcover)
.build();
MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
.title(titleProvider)
.build();
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
MetadataRefreshOptions requestOptions = new MetadataRefreshOptions(
null, MetadataProvider.Hardcover, null, null, null,
true, false, false, fieldOptions);
MetadataRefreshOptions requestOptions = MetadataRefreshOptions.builder()
.refreshCovers(true)
.mergeCategories(false)
.reviewBeforeApply(false)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.build();
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
@@ -288,9 +322,15 @@ class MetadataRefreshServiceTest {
@Test
void testRefreshMetadata_WithReviewMode_ShouldCreateTaskAndProposals() throws JsonProcessingException {
MetadataRefreshOptions reviewOptions = new MetadataRefreshOptions(
null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
true, false, true, defaultOptions.getFieldOptions());
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
MetadataRefreshOptions reviewOptions = MetadataRefreshOptions.builder()
.refreshCovers(true)
.mergeCategories(false)
.reviewBeforeApply(true)
.fieldOptions(defaultOptions.getFieldOptions())
.skipFields(skipFields)
.build();
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
@@ -449,28 +489,48 @@ class MetadataRefreshServiceTest {
@Test
void testBuildFetchMetadata_WithMergeCategories_ShouldMergeAllCategories() {
MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider descriptionProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider authorsProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider moodProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider tagProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider categoriesProvider = new MetadataRefreshOptions.FieldProvider(
null, null, MetadataProvider.Google, MetadataProvider.GoodReads);
MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider(
null, null, null, MetadataProvider.Google);
MetadataRefreshOptions.FieldProvider titleProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldProvider descriptionProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldProvider authorsProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldProvider moodProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldProvider tagProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldProvider categoriesProvider = MetadataRefreshOptions.FieldProvider.builder()
.p3(MetadataProvider.Google)
.p1(MetadataProvider.GoodReads)
.build();
MetadataRefreshOptions.FieldProvider coverProvider = MetadataRefreshOptions.FieldProvider.builder()
.p1(MetadataProvider.Google)
.build();
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
titleProvider, null, descriptionProvider, authorsProvider, null, null,
null, null, null, null, null, null, categoriesProvider, moodProvider, tagProvider, coverProvider);
MetadataRefreshOptions.FieldOptions fieldOptions = MetadataRefreshOptions.FieldOptions.builder()
.title(titleProvider)
.description(descriptionProvider)
.authors(authorsProvider)
.categories(categoriesProvider)
.moods(moodProvider)
.tags(tagProvider)
.cover(coverProvider)
.build();
MetadataRefreshOptions mergeOptions = new MetadataRefreshOptions(
null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
true, true, false, fieldOptions);
MetadataRefreshOptions.SkipFields skipFields = MetadataRefreshOptions.SkipFields.builder().build();
MetadataRefreshOptions mergeOptions = MetadataRefreshOptions.builder()
.refreshCovers(true)
.mergeCategories(true)
.reviewBeforeApply(false)
.fieldOptions(fieldOptions)
.skipFields(skipFields)
.build();
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
metadataMap.put(MetadataProvider.GoodReads, BookMetadata.builder()
@@ -177,58 +177,56 @@
</div>
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1">
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1 md:basis-[50%]">
<div class="flex flex-col gap-1 w-full">
<label class="text-sm" for="moods">Moods</label>
<div class="flex justify-between items-center gap-2">
<div class="w-full">
<p-autoComplete
class="w-full"
formControlName="moods"
[multiple]="true"
[dropdown]="false"
[suggestions]="filteredMoods"
[forceSelection]="false"
[showClear]="true"
(completeMethod)="filterMoods($event)"
(onKeyUp)="onAutoCompleteKeyUp('moods', $event)"
(onSelect)="onAutoCompleteSelect('moods', $event)">
</p-autoComplete>
</div>
@if (!book.metadata!['moodsLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('moods')" severity="success"></p-button>
}
@if (book.metadata!['moodsLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('moods')" severity="warn"></p-button>
}
<div class="flex flex-col gap-1 w-full">
<label class="text-sm" for="moods">Moods</label>
<div class="flex justify-between items-center gap-2">
<div class="w-full">
<p-autoComplete
class="w-full"
formControlName="moods"
[multiple]="true"
[dropdown]="false"
[suggestions]="filteredMoods"
[forceSelection]="false"
[showClear]="true"
(completeMethod)="filterMoods($event)"
(onKeyUp)="onAutoCompleteKeyUp('moods', $event)"
(onSelect)="onAutoCompleteSelect('moods', $event)">
</p-autoComplete>
</div>
@if (!book.metadata!['moodsLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('moods')" severity="success"></p-button>
}
@if (book.metadata!['moodsLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('moods')" severity="warn"></p-button>
}
</div>
</div>
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1 md:basis-[50%]">
<div class="flex flex-col gap-1 w-full">
<label class="text-sm" for="tags">Tags</label>
<div class="flex justify-between items-center gap-2">
<div class="w-full">
<p-autoComplete
class="w-full"
formControlName="tags"
[multiple]="true"
[dropdown]="false"
[suggestions]="filteredTags"
[forceSelection]="false"
[showClear]="true"
(completeMethod)="filterTags($event)"
(onKeyUp)="onAutoCompleteKeyUp('tags', $event)"
(onSelect)="onAutoCompleteSelect('tags', $event)">
</p-autoComplete>
</div>
@if (!book.metadata!['tagsLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('tags')" severity="success"></p-button>
}
@if (book.metadata!['tagsLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('tags')" severity="warn"></p-button>
}
</div>
<div class="flex flex-col md:flex-row w-full gap-4 mt-2 pb-1">
<div class="flex flex-col gap-1 w-full">
<label class="text-sm" for="tags">Tags</label>
<div class="flex justify-between items-center gap-2">
<div class="w-full">
<p-autoComplete
class="w-full"
formControlName="tags"
[multiple]="true"
[dropdown]="false"
[suggestions]="filteredTags"
[forceSelection]="false"
[showClear]="true"
(completeMethod)="filterTags($event)"
(onKeyUp)="onAutoCompleteKeyUp('tags', $event)"
(onSelect)="onAutoCompleteSelect('tags', $event)">
</p-autoComplete>
</div>
@if (!book.metadata!['tagsLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('tags')" severity="success"></p-button>
}
@if (book.metadata!['tagsLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('tags')" severity="warn"></p-button>
}
</div>
</div>
</div>
@@ -2,95 +2,100 @@
<table class="min-w-full table-auto border-collapse custom-table">
<thead>
<tr>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">Book Field</th>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">Skip</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">Metadata Field</th>
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
4th Priority
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="Last fallback option - only used if 1st, 2nd, and 3rd priorities fail or are empty"
tooltipPosition="top"></i>
</th>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
3rd Priority
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="Third choice - used if 1st and 2nd priorities don't have data"
tooltipPosition="top"></i>
</th>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
2nd Priority
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="Second choice - used if 1st priority doesn't have data"
tooltipPosition="top"></i>
</th>
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">
<th class="px-4 py-1.5 text-left font-semibold text-zinc-300">
1st Priority
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="First choice - always tried first for this field"
tooltipPosition="top"></i>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-sm px-4 py-1.5 text-gray-300">
All Other Fields
<i class="pi pi-question-circle ml-1 text-xs"
pTooltip="Quick way to set the same provider priority for all fields at once"
tooltipPosition="right"></i>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="allP4.value"
(onChange)="syncProvider($event, 'p4')"
placeholder="Select All" appendTo="body"
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-sm text-zinc-400 italic">Set All:</td>
<td class="px-4 py-2">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP4"
(ngModelChange)="setBulkProvider('p4', $event)"
placeholder="Set all P4" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="allP3.value"
(onChange)="syncProvider($event, 'p3')"
placeholder="Select All" appendTo="body"
<td class="px-4 py-2">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP3"
(ngModelChange)="setBulkProvider('p3', $event)"
placeholder="Set all P3" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="allP2.value"
(onChange)="syncProvider($event, 'p2')"
placeholder="Select All" appendTo="body"
<td class="px-4 py-2">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP2"
(ngModelChange)="setBulkProvider('p2', $event)"
placeholder="Set all P2" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="allP1.value"
(onChange)="syncProvider($event, 'p1')"
placeholder="Select All" appendTo="body"
<td class="px-4 py-2">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP1"
(ngModelChange)="setBulkProvider('p1', $event)"
placeholder="Set all P1" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
</tr>
@for (field of fields; track field) {
<tr [hidden]="field === 'cover' && !refreshCovers">
<td class="text-sm px-4 py-1.5 text-gray-300">{{ formatLabel(field) }}</td>
</thead>
<tbody>
@for (field of nonProviderSpecificFields; track field) {
<tr [hidden]="field === 'cover' && !refreshCovers"
[class.opacity-50]="skipFields[field]">
<td class="px-4 py-1.5">
<p-checkbox [(ngModel)]="skipFields[field]" [binary]="true"
pTooltip="Skip this field during metadata fetch"
tooltipPosition="top"></p-checkbox>
</td>
<td class="px-4 py-1.5 text-zinc-200">{{ formatLabel(field) }}</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p4"
[disabled]="skipFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p3"
[disabled]="skipFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p2"
[disabled]="skipFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
</td>
<td class="px-4 py-1.5">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p1"
[disabled]="skipFields[field]"
placeholder="Unset" appendTo="body"
class="w-full" size="small">
</p-select>
@@ -100,6 +105,20 @@
</tbody>
</table>
<div class="space-y-4">
<h3 class="text-lg font-semibold text-zinc-300">Provider-Specific Fields</h3>
<p class="text-sm text-zinc-400">These fields are unique to specific providers and cannot have custom priority settings. Use the checkboxes to skip fetching these fields entirely.</p>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@for (field of providerSpecificFields; track field) {
<div class="flex items-center space-x-3 p-3 border border-zinc-600 rounded-lg">
<p-checkbox [(ngModel)]="skipFields[field]" [binary]="true"
pTooltip="Skip this field during metadata fetch"
tooltipPosition="top"></p-checkbox>
<span class="text-sm text-zinc-300">{{ formatLabel(field) }}</span>
</div>
}
</div>
</div>
<div class="flex flex-row items-center justify-between gap-4 w-full">
@@ -1,17 +1,11 @@
import {
Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges
} from '@angular/core';
import {Select, SelectChangeEvent} from 'primeng/select';
import {Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges} from '@angular/core';
import {Select} from 'primeng/select';
import {FormsModule} from '@angular/forms';
import {Checkbox} from 'primeng/checkbox';
import {Button} from 'primeng/button';
import {MessageService} from 'primeng/api';
import {
FieldOptions,
FieldProvider,
MetadataRefreshOptions
} from '../../model/request/metadata-refresh-options.model';
import {FieldOptions, MetadataRefreshOptions} from '../../model/request/metadata-refresh-options.model';
import {Tooltip} from 'primeng/tooltip';
@Component({
@@ -30,25 +24,49 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
fields: (keyof FieldOptions)[] = [
'title', 'subtitle', 'description', 'authors', 'publisher', 'publishedDate',
'seriesName', 'seriesNumber', 'seriesTotal', 'isbn13', 'isbn10',
'language', 'categories', 'cover'
'language', 'categories', 'cover', 'pageCount',
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId',
'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount',
'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags'
];
providerSpecificFields: (keyof FieldOptions)[] = [
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId',
'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount',
'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags'
];
nonProviderSpecificFields: (keyof FieldOptions)[] = [
'title', 'subtitle', 'description', 'authors', 'publisher', 'publishedDate',
'seriesName', 'seriesNumber', 'seriesTotal', 'isbn13', 'isbn10',
'language', 'categories', 'cover', 'pageCount',
];
providers: string[] = ['Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban'];
providersWithClear: string[] = ['Clear All', 'Amazon', 'Google', 'GoodReads', 'Hardcover', 'Comicvine', 'Douban'];
refreshCovers: boolean = false;
mergeCategories: boolean = false;
reviewBeforeApply: boolean = false;
allP1 = {placeholder: 'Set All', value: null as string | null};
allP2 = {placeholder: 'Set All', value: null as string | null};
allP3 = {placeholder: 'Set All', value: null as string | null};
allP4 = {placeholder: 'Set All', value: null as string | null};
fieldOptions: FieldOptions = this.initializeFieldOptions();
skipFields: Record<keyof FieldOptions, boolean> = this.initializeSkipFields();
bulkP1: string | null = null;
bulkP2: string | null = null;
bulkP3: string | null = null;
bulkP4: string | null = null;
private messageService = inject(MessageService);
private justSubmitted = false;
private providerSpecificFieldsList = [
'asin', 'goodreadsId', 'comicvineId', 'hardcoverId', 'googleId',
'amazonRating', 'amazonReviewCount', 'goodreadsRating', 'goodreadsReviewCount',
'hardcoverRating', 'hardcoverReviewCount', 'moods', 'tags'
];
private initializeFieldOptions(): FieldOptions {
return this.fields.reduce((acc, field) => {
acc[field] = {p1: null, p2: null, p3: null, p4: null};
@@ -56,6 +74,13 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
}, {} as FieldOptions);
}
private initializeSkipFields(): Record<keyof FieldOptions, boolean> {
return this.fields.reduce((acc, field) => {
acc[field] = false;
return acc;
}, {} as Record<keyof FieldOptions, boolean>);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['currentMetadataOptions'] && this.currentMetadataOptions && !this.justSubmitted) {
this.refreshCovers = this.currentMetadataOptions.refreshCovers || false;
@@ -72,10 +97,11 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
}
this.fieldOptions = backendFieldOptions;
this.allP1 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP1 || null};
this.allP2 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP2 || null};
this.allP3 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP3 || null};
this.allP4 = {placeholder: 'Set All', value: this.currentMetadataOptions.allP4 || null};
if (this.currentMetadataOptions.skipFields) {
this.skipFields = {...this.skipFields, ...this.currentMetadataOptions.skipFields};
} else {
this.skipFields = this.initializeSkipFields();
}
}
}
@@ -92,14 +118,10 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
return cloned;
}
syncProvider(event: SelectChangeEvent, providerType: keyof FieldProvider) {
for (const field of Object.keys(this.fieldOptions)) {
this.fieldOptions[field as keyof FieldOptions][providerType] = event.value;
}
}
submit() {
const allFieldsHaveProvider = Object.values(this.fieldOptions).every(opt =>
const allFieldsHaveProvider = Object.entries(this.fieldOptions).every(([field, opt]) =>
this.skipFields[field as keyof FieldOptions] ||
this.isProviderSpecificField(field as keyof FieldOptions) ||
opt.p1 !== null || opt.p2 !== null || opt.p3 !== null || opt.p4 !== null
);
@@ -108,14 +130,11 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
const metadataRefreshOptions: MetadataRefreshOptions = {
libraryId: null,
allP1: this.allP1.value,
allP2: this.allP2.value,
allP3: this.allP3.value,
allP4: this.allP4.value,
refreshCovers: this.refreshCovers,
mergeCategories: this.mergeCategories,
reviewBeforeApply: this.reviewBeforeApply,
fieldOptions: this.fieldOptions
fieldOptions: this.fieldOptions,
skipFields: this.skipFields
};
this.metadataOptionsSubmitted.emit(metadataRefreshOptions);
@@ -127,18 +146,41 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
this.messageService.add({
severity: 'error',
summary: 'Error',
detail: 'At least one provider (P1P4) must be selected for each book field.',
detail: 'At least one provider (P1P4) must be selected for each non-skipped book field.',
life: 5000
});
}
}
setBulkProvider(priority: 'p1' | 'p2' | 'p3' | 'p4', provider: string | null): void {
if (!provider) return;
const value = provider === 'Clear All' ? null : provider;
for (const field of this.nonProviderSpecificFields) {
if (!this.skipFields[field]) {
this.fieldOptions[field][priority] = value;
}
}
switch (priority) {
case 'p1':
this.bulkP1 = null;
break;
case 'p2':
this.bulkP2 = null;
break;
case 'p3':
this.bulkP3 = null;
break;
case 'p4':
this.bulkP4 = null;
break;
}
}
reset() {
this.justSubmitted = false;
this.allP1.value = null;
this.allP2.value = null;
this.allP3.value = null;
this.allP4.value = null;
for (const field of Object.keys(this.fieldOptions)) {
this.fieldOptions[field as keyof FieldOptions] = {
p1: null,
@@ -147,9 +189,53 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
p4: null
};
}
this.skipFields = this.initializeSkipFields();
// Reset bulk selectors
this.bulkP1 = null;
this.bulkP2 = null;
this.bulkP3 = null;
this.bulkP4 = null;
}
formatLabel(field: string): string {
return field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim();
const fieldLabels: Record<string, string> = {
'title': 'Title',
'subtitle': 'Subtitle',
'description': 'Description',
'authors': 'Authors',
'publisher': 'Publisher',
'publishedDate': 'Published Date',
'seriesName': 'Series Name',
'seriesNumber': 'Series Number',
'seriesTotal': 'Series Total',
'isbn13': 'ISBN-13',
'isbn10': 'ISBN-10',
'language': 'Language',
'categories': 'Genres',
'cover': 'Cover Image',
'pageCount': 'Page Count',
'rating': 'Rating',
'reviewCount': 'Review Count',
'asin': 'Amazon ASIN',
'goodreadsId': 'Goodreads ID',
'comicvineId': 'Comicvine ID',
'hardcoverId': 'Hardcover ID',
'googleId': 'Google Books ID',
'amazonRating': 'Amazon Rating',
'amazonReviewCount': 'Amazon Review Count',
'goodreadsRating': 'Goodreads Rating',
'goodreadsReviewCount': 'Goodreads Review Count',
'hardcoverRating': 'Hardcover Rating',
'hardcoverReviewCount': 'Hardcover Review Count',
'moods': 'Moods (Hardcover)',
'tags': 'Tags (Hardcover)'
};
return fieldLabels[field] || field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim();
}
isProviderSpecificField(field: keyof FieldOptions): boolean {
return this.providerSpecificFieldsList.includes(field as string);
}
}
@@ -1,13 +1,10 @@
export interface MetadataRefreshOptions {
libraryId: number | null;
allP4: string | null;
allP3: string | null;
allP2: string | null;
allP1: string | null;
refreshCovers: boolean;
mergeCategories: boolean;
reviewBeforeApply: boolean;
fieldOptions?: FieldOptions;
skipFields?: Record<keyof FieldOptions, boolean>;
}
export interface FieldProvider {
@@ -32,4 +29,18 @@ export interface FieldOptions {
isbn13: FieldProvider;
isbn10: FieldProvider;
language: FieldProvider;
pageCount: FieldProvider;
asin: FieldProvider;
goodreadsId: FieldProvider;
comicvineId: FieldProvider;
hardcoverId: FieldProvider;
googleId: FieldProvider;
amazonRating: FieldProvider;
amazonReviewCount: FieldProvider;
goodreadsRating: FieldProvider;
goodreadsReviewCount: FieldProvider;
hardcoverRating: FieldProvider;
hardcoverReviewCount: FieldProvider;
moods: FieldProvider;
tags: FieldProvider;
}
@@ -184,10 +184,6 @@ export class LibraryMetadataSettingsComponent implements OnInit {
private getDefaultMetadataOptions(): MetadataRefreshOptions {
return {
libraryId: null,
allP1: null,
allP2: null,
allP3: null,
allP4: null,
refreshCovers: false,
mergeCategories: false,
reviewBeforeApply: false,
@@ -205,9 +201,22 @@ export class LibraryMetadataSettingsComponent implements OnInit {
isbn10: {p1: null, p2: null, p3: null, p4: null},
language: {p1: null, p2: null, p3: null, p4: null},
categories: {p1: null, p2: null, p3: null, p4: null},
cover: {p1: null, p2: null, p3: null, p4: null}
cover: {p1: null, p2: null, p3: null, p4: null},
pageCount: {p1: null, p2: null, p3: null, p4: null},
asin: {p1: null, p2: null, p3: null, p4: null},
goodreadsId: {p1: null, p2: null, p3: null, p4: null},
comicvineId: {p1: null, p2: null, p3: null, p4: null},
hardcoverId: {p1: null, p2: null, p3: null, p4: null},
googleId: {p1: null, p2: null, p3: null, p4: null},
amazonRating: {p1: null, p2: null, p3: null, p4: null},
amazonReviewCount: {p1: null, p2: null, p3: null, p4: null},
goodreadsRating: {p1: null, p2: null, p3: null, p4: null},
goodreadsReviewCount: {p1: null, p2: null, p3: null, p4: null},
hardcoverRating: {p1: null, p2: null, p3: null, p4: null},
hardcoverReviewCount: {p1: null, p2: null, p3: null, p4: null},
moods: {p1: null, p2: null, p3: null, p4: null},
tags: {p1: null, p2: null, p3: null, p4: null}
}
};
}
}