mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-06 07:50:03 -06:00
Introduce per-library settings for metadata fetching (#1239)
This commit is contained in:
@@ -7,9 +7,7 @@ import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.request.UserLoginRequest;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.RefreshTokenEntity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserRepository;
|
||||
import com.adityachandel.booklore.repository.RefreshTokenRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.service.user.DefaultSettingInitializer;
|
||||
@@ -19,7 +17,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import lombok.AllArgsConstructor;
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MetadataRefreshOptions {
|
||||
private Long libraryId;
|
||||
@NotNull(message = "Default Provider cannot be null")
|
||||
private MetadataProvider allP1;
|
||||
private MetadataProvider allP2;
|
||||
|
||||
@@ -11,7 +11,6 @@ import java.util.Set;
|
||||
public class MetadataRefreshRequest {
|
||||
@NotNull(message = "Refresh type cannot be null")
|
||||
private RefreshType refreshType;
|
||||
private Boolean quick;
|
||||
private Long libraryId;
|
||||
private Set<Long> bookIds;
|
||||
private MetadataRefreshOptions refreshOptions;
|
||||
|
||||
@@ -7,6 +7,7 @@ public enum AppSettingKey {
|
||||
OIDC_PROVIDER_DETAILS("oidc_provider_details", true, true),
|
||||
|
||||
QUICK_BOOK_MATCH("quick_book_match", true, false),
|
||||
LIBRARY_METADATA_REFRESH_OPTIONS("library_metadata_refresh_options", true, false),
|
||||
OIDC_AUTO_PROVISION_DETAILS("oidc_auto_provision_details", true, false),
|
||||
SIDEBAR_LIBRARY_SORTING("sidebar_library_sorting", true, false),
|
||||
SIDEBAR_SHELF_SORTING("sidebar_shelf_sorting", true, false),
|
||||
|
||||
@@ -6,12 +6,15 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class AppSettings {
|
||||
private MetadataRefreshOptions metadataRefreshOptions;
|
||||
private MetadataRefreshOptions defaultMetadataRefreshOptions;
|
||||
private List<MetadataRefreshOptions> libraryMetadataRefreshOptions;
|
||||
private boolean autoBookSearch;
|
||||
private boolean similarBookRecommendation;
|
||||
private boolean opdsServerEnabled;
|
||||
|
||||
@@ -5,10 +5,12 @@ import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
|
||||
import com.adityachandel.booklore.model.dto.settings.*;
|
||||
import com.adityachandel.booklore.model.entity.AppSettingEntity;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import jakarta.transaction.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -82,7 +84,8 @@ public class AppSettingService {
|
||||
AppSettings.AppSettingsBuilder builder = AppSettings.builder();
|
||||
builder.remoteAuthEnabled(appProperties.getRemoteAuth().isEnabled());
|
||||
|
||||
builder.metadataRefreshOptions(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.QUICK_BOOK_MATCH, MetadataRefreshOptions.class, settingPersistenceHelper.getDefaultMetadataRefreshOptions(), true));
|
||||
builder.defaultMetadataRefreshOptions(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.QUICK_BOOK_MATCH, MetadataRefreshOptions.class, settingPersistenceHelper.getDefaultMetadataRefreshOptions(), true));
|
||||
builder.libraryMetadataRefreshOptions(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.LIBRARY_METADATA_REFRESH_OPTIONS, new TypeReference<List<MetadataRefreshOptions>>() {}, List.of(), true));
|
||||
builder.oidcProviderDetails(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.OIDC_PROVIDER_DETAILS, OidcProviderDetails.class, null, false));
|
||||
builder.oidcAutoProvisionDetails(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.OIDC_AUTO_PROVISION_DETAILS, OidcAutoProvisionDetails.class, null, false));
|
||||
builder.metadataProviderSettings(settingPersistenceHelper.getJsonSetting(settingsMap, AppSettingKey.METADATA_PROVIDER_SETTINGS, MetadataProviderSettings.class, settingPersistenceHelper.getDefaultMetadataProviderSettings(), true));
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.adityachandel.booklore.model.entity.AppSettingEntity;
|
||||
import com.adityachandel.booklore.model.enums.MetadataProvider;
|
||||
import com.adityachandel.booklore.repository.AppSettingsRepository;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -36,10 +37,20 @@ public class SettingPersistenceHelper {
|
||||
}
|
||||
|
||||
public <T> T getJsonSetting(Map<String, String> settingsMap, AppSettingKey key, Class<T> clazz, T defaultValue, boolean persistDefault) {
|
||||
return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault,
|
||||
json -> objectMapper.readValue(json, clazz));
|
||||
}
|
||||
|
||||
public <T> T getJsonSetting(Map<String, String> settingsMap, AppSettingKey key, TypeReference<T> typeReference, T defaultValue, boolean persistDefault) {
|
||||
return getJsonSettingInternal(settingsMap, key, defaultValue, persistDefault,
|
||||
json -> objectMapper.readValue(json, typeReference));
|
||||
}
|
||||
|
||||
private <T> T getJsonSettingInternal(Map<String, String> settingsMap, AppSettingKey key, T defaultValue, boolean persistDefault, JsonDeserializer<T> deserializer) {
|
||||
String json = settingsMap.get(key.toString());
|
||||
if (json != null && !json.isBlank()) {
|
||||
try {
|
||||
return objectMapper.readValue(json, clazz);
|
||||
return deserializer.deserialize(json);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new RuntimeException("Failed to parse " + key, e);
|
||||
}
|
||||
@@ -54,6 +65,11 @@ public class SettingPersistenceHelper {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface JsonDeserializer<T> {
|
||||
T deserialize(String json) throws JsonProcessingException;
|
||||
}
|
||||
|
||||
public String serializeSettingValue(AppSettingKey key, Object val) throws JsonProcessingException {
|
||||
return key.isJson() ? objectMapper.writeValueAsString(val) : val.toString();
|
||||
}
|
||||
@@ -141,6 +157,7 @@ public class SettingPersistenceHelper {
|
||||
);
|
||||
|
||||
return new MetadataRefreshOptions(
|
||||
null,
|
||||
MetadataProvider.GoodReads,
|
||||
MetadataProvider.Amazon,
|
||||
MetadataProvider.Google,
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.adityachandel.booklore.service.bookdrop;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.BookMetadata;
|
||||
import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
|
||||
import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest;
|
||||
import com.adityachandel.booklore.model.dto.settings.AppSettings;
|
||||
import com.adityachandel.booklore.model.entity.BookdropFileEntity;
|
||||
@@ -61,13 +62,12 @@ public class BookdropMetadataService {
|
||||
BookdropFileEntity entity = getOrThrow(bookdropFileId);
|
||||
|
||||
AppSettings appSettings = appSettingService.getAppSettings();
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshOptions(appSettings.getMetadataRefreshOptions())
|
||||
.build();
|
||||
|
||||
MetadataRefreshOptions refreshOptions = appSettings.getDefaultMetadataRefreshOptions();
|
||||
|
||||
BookMetadata initial = objectMapper.readValue(entity.getOriginalMetadata(), BookMetadata.class);
|
||||
|
||||
List<MetadataProvider> providers = metadataRefreshService.prepareProviders(request);
|
||||
List<MetadataProvider> providers = metadataRefreshService.prepareProviders(refreshOptions);
|
||||
Book book = Book.builder()
|
||||
.fileName(entity.getFileName())
|
||||
.metadata(initial)
|
||||
@@ -82,7 +82,7 @@ public class BookdropMetadataService {
|
||||
}
|
||||
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = metadataRefreshService.fetchMetadataForBook(providers, book);
|
||||
BookMetadata fetchedMetadata = metadataRefreshService.buildFetchMetadata(book.getId(), request, metadataMap);
|
||||
BookMetadata fetchedMetadata = metadataRefreshService.buildFetchMetadata(book.getId(), refreshOptions, metadataMap);
|
||||
String fetchedJson = objectMapper.writeValueAsString(fetchedMetadata);
|
||||
|
||||
entity.setFetchedMetadata(fetchedJson);
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.adityachandel.booklore.model.websocket.Topic;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.LibraryRepository;
|
||||
import com.adityachandel.booklore.repository.MetadataFetchJobRepository;
|
||||
import com.adityachandel.booklore.repository.MetadataFetchProposalRepository;
|
||||
import com.adityachandel.booklore.service.NotificationService;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.service.metadata.parser.BookParser;
|
||||
@@ -27,8 +26,6 @@ import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -46,7 +43,6 @@ public class MetadataRefreshService {
|
||||
|
||||
private final LibraryRepository libraryRepository;
|
||||
private final MetadataFetchJobRepository metadataFetchJobRepository;
|
||||
private final MetadataFetchProposalRepository metadataFetchProposalRepository;
|
||||
private final BookMapper bookMapper;
|
||||
private final BookMetadataUpdater bookMetadataUpdater;
|
||||
private final NotificationService notificationService;
|
||||
@@ -59,15 +55,23 @@ public class MetadataRefreshService {
|
||||
|
||||
public void refreshMetadata(MetadataRefreshRequest request, Long userId, String jobId) {
|
||||
try {
|
||||
if (Boolean.TRUE.equals(request.getQuick())) {
|
||||
AppSettings appSettings = appSettingService.getAppSettings();
|
||||
request.setRefreshOptions(appSettings.getMetadataRefreshOptions());
|
||||
}
|
||||
AppSettings appSettings = appSettingService.getAppSettings();
|
||||
|
||||
final boolean isLibraryRefresh = request.getRefreshType() == MetadataRefreshRequest.RefreshType.LIBRARY;
|
||||
final MetadataRefreshOptions requestRefreshOptions = request.getRefreshOptions();
|
||||
|
||||
final boolean useRequestOptions = requestRefreshOptions != null;
|
||||
final MetadataRefreshOptions libraryRefreshOptions = !useRequestOptions && isLibraryRefresh ? resolveMetadataRefreshOptions(request.getLibraryId(), appSettings) : null;
|
||||
final List<MetadataProvider> fixedProviders = useRequestOptions ?
|
||||
prepareProviders(requestRefreshOptions) :
|
||||
(isLibraryRefresh ? prepareProviders(libraryRefreshOptions) : null);
|
||||
|
||||
List<MetadataProvider> providers = prepareProviders(request);
|
||||
Set<Long> bookIds = getBookEntities(request);
|
||||
|
||||
boolean isReviewMode = Boolean.TRUE.equals(request.getRefreshOptions().getReviewBeforeApply());
|
||||
MetadataRefreshOptions reviewModeOptions = requestRefreshOptions != null ?
|
||||
requestRefreshOptions :
|
||||
(libraryRefreshOptions != null ? libraryRefreshOptions : appSettings.getDefaultMetadataRefreshOptions());
|
||||
boolean isReviewMode = Boolean.TRUE.equals(reviewModeOptions.getReviewBeforeApply());
|
||||
MetadataFetchJobEntity task;
|
||||
|
||||
if (isReviewMode) {
|
||||
@@ -101,6 +105,21 @@ public class MetadataRefreshService {
|
||||
sendTaskNotification(jobId, "Skipped locked book: " + book.getMetadata().getTitle(), TaskStatus.IN_PROGRESS);
|
||||
return null;
|
||||
}
|
||||
|
||||
MetadataRefreshOptions refreshOptions;
|
||||
List<MetadataProvider> providers;
|
||||
|
||||
if (useRequestOptions) {
|
||||
refreshOptions = requestRefreshOptions;
|
||||
providers = fixedProviders;
|
||||
} else if (isLibraryRefresh) {
|
||||
refreshOptions = libraryRefreshOptions;
|
||||
providers = fixedProviders;
|
||||
} else {
|
||||
refreshOptions = resolveMetadataRefreshOptions(book.getLibrary().getId(), appSettings);
|
||||
providers = prepareProviders(refreshOptions);
|
||||
}
|
||||
|
||||
reportProgressIfNeeded(task, jobId, finalCompletedCount, bookIds.size(), book);
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = fetchMetadataForBook(providers, book);
|
||||
if (providers.contains(GoodReads)) {
|
||||
@@ -112,11 +131,13 @@ public class MetadataRefreshService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
BookMetadata fetched = buildFetchMetadata(book.getId(), request, metadataMap);
|
||||
if (isReviewMode) {
|
||||
BookMetadata fetched = buildFetchMetadata(book.getId(), refreshOptions, metadataMap);
|
||||
|
||||
boolean bookReviewMode = Boolean.TRUE.equals(refreshOptions.getReviewBeforeApply());
|
||||
if (bookReviewMode) {
|
||||
saveProposal(task, book.getId(), fetched);
|
||||
} else {
|
||||
updateBookMetadata(book, fetched, request.getRefreshOptions().isRefreshCovers(), request.getRefreshOptions().isMergeCategories());
|
||||
updateBookMetadata(book, fetched, refreshOptions.isRefreshCovers(), refreshOptions.isMergeCategories());
|
||||
sendTaskProgressNotification(jobId, finalCompletedCount + 1, bookIds.size(), "Metadata updated: " + book.getMetadata().getTitle());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -154,6 +175,20 @@ public class MetadataRefreshService {
|
||||
}
|
||||
}
|
||||
|
||||
MetadataRefreshOptions resolveMetadataRefreshOptions(Long libraryId, AppSettings appSettings) {
|
||||
MetadataRefreshOptions defaultOptions = appSettings.getDefaultMetadataRefreshOptions();
|
||||
List<MetadataRefreshOptions> libraryOptions = appSettings.getLibraryMetadataRefreshOptions();
|
||||
|
||||
if (libraryId != null && libraryOptions != null) {
|
||||
return libraryOptions.stream()
|
||||
.filter(options -> libraryId.equals(options.getLibraryId()))
|
||||
.findFirst()
|
||||
.orElse(defaultOptions);
|
||||
}
|
||||
|
||||
return defaultOptions;
|
||||
}
|
||||
|
||||
public Map<MetadataProvider, BookMetadata> fetchMetadataForBook(List<MetadataProvider> providers, Book book) {
|
||||
return providers.stream()
|
||||
.map(provider -> createInterruptibleMetadataFuture(() -> fetchTopMetadataFromAProvider(provider, book)))
|
||||
@@ -296,13 +331,13 @@ public class MetadataRefreshService {
|
||||
}
|
||||
}
|
||||
|
||||
public List<MetadataProvider> prepareProviders(MetadataRefreshRequest request) {
|
||||
Set<MetadataProvider> allProviders = new HashSet<>(getAllProvidersUsingIndividualFields(request));
|
||||
public List<MetadataProvider> prepareProviders(MetadataRefreshOptions refreshOptions) {
|
||||
Set<MetadataProvider> allProviders = new HashSet<>(getAllProvidersUsingIndividualFields(refreshOptions));
|
||||
return new ArrayList<>(allProviders);
|
||||
}
|
||||
|
||||
protected Set<MetadataProvider> getAllProvidersUsingIndividualFields(MetadataRefreshRequest request) {
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = request.getRefreshOptions().getFieldOptions();
|
||||
protected Set<MetadataProvider> getAllProvidersUsingIndividualFields(MetadataRefreshOptions refreshOptions) {
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
|
||||
Set<MetadataProvider> uniqueProviders = new HashSet<>();
|
||||
|
||||
if (fieldOptions != null) {
|
||||
@@ -349,9 +384,9 @@ public class MetadataRefreshService {
|
||||
.build();
|
||||
}
|
||||
|
||||
public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshRequest request, Map<MetadataProvider, BookMetadata> metadataMap) {
|
||||
public BookMetadata buildFetchMetadata(Long bookId, MetadataRefreshOptions refreshOptions, Map<MetadataProvider, BookMetadata> metadataMap) {
|
||||
BookMetadata metadata = BookMetadata.builder().bookId(bookId).build();
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = request.getRefreshOptions().getFieldOptions();
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = refreshOptions.getFieldOptions();
|
||||
|
||||
metadata.setTitle(resolveFieldAsString(metadataMap, fieldOptions.getTitle(), BookMetadata::getTitle));
|
||||
metadata.setDescription(resolveFieldAsString(metadataMap, fieldOptions.getDescription(), BookMetadata::getDescription));
|
||||
@@ -378,24 +413,24 @@ public class MetadataRefreshService {
|
||||
metadata.setComicvineId(metadataMap.get(Comicvine).getComicvineId());
|
||||
}
|
||||
|
||||
if (request.getRefreshOptions().isMergeCategories()) {
|
||||
if (refreshOptions.isMergeCategories()) {
|
||||
metadata.setCategories(getAllCategories(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
|
||||
} else {
|
||||
metadata.setCategories(resolveFieldAsList(metadataMap, fieldOptions.getCategories(), BookMetadata::getCategories));
|
||||
}
|
||||
metadata.setThumbnailUrl(resolveFieldAsString(metadataMap, fieldOptions.getCover(), BookMetadata::getThumbnailUrl));
|
||||
|
||||
if (request.getRefreshOptions().getAllP4() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, request.getRefreshOptions().getAllP4());
|
||||
if (refreshOptions.getAllP4() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP4());
|
||||
}
|
||||
if (request.getRefreshOptions().getAllP3() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, request.getRefreshOptions().getAllP3());
|
||||
if (refreshOptions.getAllP3() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP3());
|
||||
}
|
||||
if (request.getRefreshOptions().getAllP2() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, request.getRefreshOptions().getAllP2());
|
||||
if (refreshOptions.getAllP2() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP2());
|
||||
}
|
||||
if (request.getRefreshOptions().getAllP1() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, request.getRefreshOptions().getAllP1());
|
||||
if (refreshOptions.getAllP1() != null) {
|
||||
setOtherUnspecifiedMetadata(metadataMap, metadata, refreshOptions.getAllP1());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
package com.adityachandel.booklore.service.metadata;
|
||||
|
||||
import com.adityachandel.booklore.mapper.BookMapper;
|
||||
import com.adityachandel.booklore.model.MetadataUpdateWrapper;
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.BookMetadata;
|
||||
import com.adityachandel.booklore.model.dto.TaskMessage;
|
||||
import com.adityachandel.booklore.model.dto.request.FetchMetadataRequest;
|
||||
import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
|
||||
import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest;
|
||||
import com.adityachandel.booklore.model.dto.settings.AppSettings;
|
||||
import com.adityachandel.booklore.model.entity.*;
|
||||
import com.adityachandel.booklore.model.enums.MetadataProvider;
|
||||
import com.adityachandel.booklore.model.websocket.Topic;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.LibraryRepository;
|
||||
import com.adityachandel.booklore.repository.MetadataFetchJobRepository;
|
||||
import com.adityachandel.booklore.service.NotificationService;
|
||||
import com.adityachandel.booklore.service.appsettings.AppSettingService;
|
||||
import com.adityachandel.booklore.service.metadata.parser.BookParser;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MetadataRefreshServiceTest {
|
||||
|
||||
@Mock
|
||||
private LibraryRepository libraryRepository;
|
||||
@Mock
|
||||
private MetadataFetchJobRepository metadataFetchJobRepository;
|
||||
@Mock
|
||||
private BookMapper bookMapper;
|
||||
@Mock
|
||||
private BookMetadataUpdater bookMetadataUpdater;
|
||||
@Mock
|
||||
private NotificationService notificationService;
|
||||
@Mock
|
||||
private AppSettingService appSettingService;
|
||||
@Mock
|
||||
private Map<MetadataProvider, BookParser> parserMap;
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
@Mock
|
||||
private PlatformTransactionManager transactionManager;
|
||||
@Mock
|
||||
private BookParser goodreadsParser;
|
||||
@Mock
|
||||
private BookParser googleParser;
|
||||
@Mock
|
||||
private BookParser hardcoverParser;
|
||||
|
||||
@InjectMocks
|
||||
private MetadataRefreshService metadataRefreshService;
|
||||
|
||||
private AppSettings appSettings;
|
||||
private MetadataRefreshOptions defaultOptions;
|
||||
private MetadataRefreshOptions libraryOptions;
|
||||
private BookEntity testBook;
|
||||
private LibraryEntity testLibrary;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
setupDefaultOptions();
|
||||
setupLibraryOptions();
|
||||
setupAppSettings();
|
||||
setupTestEntities();
|
||||
}
|
||||
|
||||
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 coverProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.GoodReads);
|
||||
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
|
||||
titleProvider, null, descriptionProvider, authorsProvider, null, null,
|
||||
null, null, null, null, null, null, categoriesProvider, coverProvider);
|
||||
|
||||
defaultOptions = new MetadataRefreshOptions(
|
||||
null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
|
||||
true, false, false, fieldOptions);
|
||||
}
|
||||
|
||||
private void setupLibraryOptions() {
|
||||
MetadataRefreshOptions.FieldProvider titleProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Google);
|
||||
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
|
||||
titleProvider, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null);
|
||||
|
||||
libraryOptions = new MetadataRefreshOptions(
|
||||
1L, MetadataProvider.Google, null, null, null,
|
||||
false, true, true, fieldOptions);
|
||||
}
|
||||
|
||||
private void setupAppSettings() {
|
||||
appSettings = AppSettings.builder()
|
||||
.defaultMetadataRefreshOptions(defaultOptions)
|
||||
.libraryMetadataRefreshOptions(List.of(libraryOptions))
|
||||
.build();
|
||||
}
|
||||
|
||||
private void setupTestEntities() {
|
||||
testLibrary = new LibraryEntity();
|
||||
testLibrary.setId(1L);
|
||||
testLibrary.setName("Test Library");
|
||||
|
||||
// Create AuthorEntity for proper type compatibility
|
||||
AuthorEntity authorEntity = new AuthorEntity();
|
||||
authorEntity.setName("Test Author");
|
||||
|
||||
BookMetadataEntity metadata = new BookMetadataEntity();
|
||||
metadata.setTitle("Test Book");
|
||||
metadata.setAuthors(Set.of(authorEntity));
|
||||
|
||||
testBook = new BookEntity();
|
||||
testBook.setId(1L);
|
||||
testBook.setFileName("test-book.epub");
|
||||
testBook.setLibrary(testLibrary);
|
||||
testBook.setMetadata(metadata);
|
||||
}
|
||||
|
||||
private void setupBasicMocks() {
|
||||
when(appSettingService.getAppSettings()).thenReturn(appSettings);
|
||||
}
|
||||
|
||||
private void setupBookRepositoryMocks() {
|
||||
when(bookRepository.findAllWithMetadataByIds(Set.of(1L))).thenReturn(List.of(testBook));
|
||||
}
|
||||
|
||||
private void setupParserMocksForGoodreadsAndGoogle() {
|
||||
BookMetadata goodreadsMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.GoodReads)
|
||||
.title("Goodreads Title")
|
||||
.authors(Set.of("Author 1"))
|
||||
.build();
|
||||
|
||||
BookMetadata googleMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Google)
|
||||
.description("Google Description")
|
||||
.categories(Set.of("Fiction"))
|
||||
.build();
|
||||
|
||||
when(parserMap.get(MetadataProvider.GoodReads)).thenReturn(goodreadsParser);
|
||||
when(parserMap.get(MetadataProvider.Google)).thenReturn(googleParser);
|
||||
|
||||
when(goodreadsParser.fetchTopMetadata(any(Book.class), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(null);
|
||||
when(googleParser.fetchTopMetadata(any(Book.class), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(googleMetadata);
|
||||
|
||||
Book book = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("test-book.epub")
|
||||
.metadata(BookMetadata.builder().title("Test Book").authors(Set.of("Test Author")).build())
|
||||
.build();
|
||||
when(bookMapper.toBook(testBook)).thenReturn(book);
|
||||
}
|
||||
|
||||
private void setupParserMocksForGoogle() {
|
||||
BookMetadata googleMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Google)
|
||||
.title("Google Title")
|
||||
.description("Google Description")
|
||||
.categories(Set.of("Fiction"))
|
||||
.build();
|
||||
|
||||
when(parserMap.get(MetadataProvider.Google)).thenReturn(googleParser);
|
||||
when(googleParser.fetchTopMetadata(any(Book.class), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(googleMetadata);
|
||||
|
||||
Book book = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("test-book.epub")
|
||||
.metadata(BookMetadata.builder().title("Test Book").authors(Set.of("Test Author")).build())
|
||||
.build();
|
||||
when(bookMapper.toBook(testBook)).thenReturn(book);
|
||||
}
|
||||
|
||||
private void setupParserMocksForHardcover() {
|
||||
BookMetadata hardcoverMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Hardcover)
|
||||
.title("Hardcover Title")
|
||||
.build();
|
||||
|
||||
when(parserMap.get(MetadataProvider.Hardcover)).thenReturn(hardcoverParser);
|
||||
when(hardcoverParser.fetchTopMetadata(any(Book.class), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(hardcoverMetadata);
|
||||
|
||||
Book book = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("test-book.epub")
|
||||
.metadata(BookMetadata.builder().title("Test Book").authors(Set.of("Test Author")).build())
|
||||
.build();
|
||||
when(bookMapper.toBook(testBook)).thenReturn(book);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
MetadataRefreshOptions requestOptions = new MetadataRefreshOptions(
|
||||
null, MetadataProvider.Hardcover, null, null, null,
|
||||
true, false, false, fieldOptions);
|
||||
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L))
|
||||
.refreshOptions(requestOptions)
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
setupBookRepositoryMocks();
|
||||
setupParserMocksForHardcover();
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
|
||||
verify(notificationService, atLeastOnce()).sendMessage(eq(Topic.TASK), any(TaskMessage.class));
|
||||
verify(bookRepository).findAllWithMetadataByIds(Set.of(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_LibraryRefresh_ShouldUseLibraryOptions() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.LIBRARY)
|
||||
.libraryId(1L)
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
when(libraryRepository.findById(1L)).thenReturn(Optional.of(testLibrary));
|
||||
when(bookRepository.findBookIdsByLibraryId(1L)).thenReturn(Set.of(1L));
|
||||
setupBookRepositoryMocks();
|
||||
setupParserMocksForGoogle();
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
|
||||
verify(libraryRepository).findById(1L);
|
||||
verify(bookRepository).findBookIdsByLibraryId(1L);
|
||||
verify(bookRepository).findAllWithMetadataByIds(Set.of(1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_BookRefresh_ShouldUsePerBookLibraryOptions() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L))
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
setupBookRepositoryMocks();
|
||||
setupParserMocksForGoogle();
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
|
||||
verify(bookRepository).findAllWithMetadataByIds(Set.of(1L));
|
||||
verify(notificationService, atLeastOnce()).sendMessage(eq(Topic.TASK), any(TaskMessage.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_WithReviewMode_ShouldCreateTaskAndProposals() throws JsonProcessingException {
|
||||
MetadataRefreshOptions reviewOptions = new MetadataRefreshOptions(
|
||||
null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
|
||||
true, false, true, defaultOptions.getFieldOptions());
|
||||
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L))
|
||||
.refreshOptions(reviewOptions)
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
setupBookRepositoryMocks();
|
||||
setupParserMocksForGoodreadsAndGoogle();
|
||||
when(objectMapper.writeValueAsString(any())).thenReturn("{}");
|
||||
|
||||
MetadataFetchJobEntity savedTask = new MetadataFetchJobEntity();
|
||||
when(metadataFetchJobRepository.save(any(MetadataFetchJobEntity.class))).thenReturn(savedTask);
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
|
||||
ArgumentCaptor<MetadataFetchJobEntity> taskCaptor = ArgumentCaptor.forClass(MetadataFetchJobEntity.class);
|
||||
verify(metadataFetchJobRepository, atLeast(1)).save(taskCaptor.capture());
|
||||
|
||||
MetadataFetchJobEntity capturedTask = taskCaptor.getValue();
|
||||
assertNotNull(capturedTask);
|
||||
verify(objectMapper).writeValueAsString(any(BookMetadata.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_LockedBook_ShouldSkip() {
|
||||
BookMetadataEntity lockedMetadata = spy(testBook.getMetadata());
|
||||
when(lockedMetadata.areAllFieldsLocked()).thenReturn(true);
|
||||
testBook.setMetadata(lockedMetadata);
|
||||
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L))
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
setupBookRepositoryMocks();
|
||||
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1");
|
||||
|
||||
verify(notificationService).sendMessage(eq(Topic.TASK), argThat(msg ->
|
||||
((TaskMessage) msg).getMessage().contains("Skipped locked book")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_BookNotFound_ShouldThrowException() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(999L))
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
when(bookRepository.findAllWithMetadataByIds(Set.of(999L))).thenReturn(Collections.emptyList());
|
||||
|
||||
assertThrows(RuntimeException.class, () ->
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRefreshMetadata_LibraryNotFound_ShouldThrowException() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.LIBRARY)
|
||||
.libraryId(999L)
|
||||
.build();
|
||||
|
||||
setupBasicMocks();
|
||||
when(libraryRepository.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(RuntimeException.class, () ->
|
||||
metadataRefreshService.refreshMetadata(request, 1L, "job-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResolveMetadataRefreshOptions_WithLibraryId_ShouldReturnLibraryOptions() {
|
||||
MetadataRefreshOptions result = metadataRefreshService.resolveMetadataRefreshOptions(1L, appSettings);
|
||||
|
||||
assertEquals(libraryOptions, result);
|
||||
assertTrue(result.getReviewBeforeApply());
|
||||
assertFalse(result.isRefreshCovers());
|
||||
assertTrue(result.isMergeCategories());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResolveMetadataRefreshOptions_WithoutLibraryId_ShouldReturnDefaultOptions() {
|
||||
MetadataRefreshOptions result = metadataRefreshService.resolveMetadataRefreshOptions(null, appSettings);
|
||||
|
||||
assertEquals(defaultOptions, result);
|
||||
assertFalse(result.getReviewBeforeApply());
|
||||
assertTrue(result.isRefreshCovers());
|
||||
assertFalse(result.isMergeCategories());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResolveMetadataRefreshOptions_NonExistentLibrary_ShouldReturnDefaultOptions() {
|
||||
MetadataRefreshOptions result = metadataRefreshService.resolveMetadataRefreshOptions(999L, appSettings);
|
||||
|
||||
assertEquals(defaultOptions, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testPrepareProviders_ShouldReturnUniqueProviders() {
|
||||
List<MetadataProvider> providers = metadataRefreshService.prepareProviders(defaultOptions);
|
||||
|
||||
assertNotNull(providers);
|
||||
assertTrue(providers.contains(MetadataProvider.GoodReads));
|
||||
assertTrue(providers.contains(MetadataProvider.Google));
|
||||
assertEquals(2, providers.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFetchMetadataForBook_ShouldReturnMetadataMap() {
|
||||
Book book = Book.builder()
|
||||
.id(1L)
|
||||
.fileName("test.epub")
|
||||
.metadata(BookMetadata.builder().title("Test").build())
|
||||
.build();
|
||||
|
||||
BookMetadata goodreadsMetadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.GoodReads)
|
||||
.title("Goodreads Title")
|
||||
.build();
|
||||
|
||||
when(parserMap.get(MetadataProvider.GoodReads)).thenReturn(goodreadsParser);
|
||||
when(goodreadsParser.fetchTopMetadata(eq(book), any(FetchMetadataRequest.class)))
|
||||
.thenReturn(goodreadsMetadata);
|
||||
|
||||
List<MetadataProvider> providers = List.of(MetadataProvider.GoodReads);
|
||||
|
||||
Map<MetadataProvider, BookMetadata> result = metadataRefreshService.fetchMetadataForBook(providers, book);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(goodreadsMetadata, result.get(MetadataProvider.GoodReads));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBuildFetchMetadata_ShouldCombineMetadataCorrectly() {
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
|
||||
metadataMap.put(MetadataProvider.GoodReads, BookMetadata.builder()
|
||||
.title("Goodreads Title")
|
||||
.authors(Set.of("Author 1"))
|
||||
.goodreadsId("gr123")
|
||||
.build());
|
||||
metadataMap.put(MetadataProvider.Google, BookMetadata.builder()
|
||||
.description("Google Description")
|
||||
.categories(Set.of("Fiction"))
|
||||
.googleId("google123")
|
||||
.build());
|
||||
|
||||
BookMetadata result = metadataRefreshService.buildFetchMetadata(1L, defaultOptions, metadataMap);
|
||||
|
||||
assertEquals("Goodreads Title", result.getTitle());
|
||||
assertEquals("Google Description", result.getDescription());
|
||||
assertEquals(Set.of("Author 1"), result.getAuthors());
|
||||
assertEquals(Set.of("Fiction"), result.getCategories());
|
||||
assertEquals("gr123", result.getGoodreadsId());
|
||||
assertEquals("google123", result.getGoogleId());
|
||||
}
|
||||
|
||||
@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 categoriesProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, MetadataProvider.Google, MetadataProvider.GoodReads);
|
||||
MetadataRefreshOptions.FieldProvider coverProvider = new MetadataRefreshOptions.FieldProvider(
|
||||
null, null, null, MetadataProvider.Google);
|
||||
|
||||
MetadataRefreshOptions.FieldOptions fieldOptions = new MetadataRefreshOptions.FieldOptions(
|
||||
titleProvider, null, descriptionProvider, authorsProvider, null, null,
|
||||
null, null, null, null, null, null, categoriesProvider, coverProvider);
|
||||
|
||||
MetadataRefreshOptions mergeOptions = new MetadataRefreshOptions(
|
||||
null, MetadataProvider.GoodReads, MetadataProvider.Google, null, null,
|
||||
true, true, false, fieldOptions);
|
||||
|
||||
Map<MetadataProvider, BookMetadata> metadataMap = new HashMap<>();
|
||||
metadataMap.put(MetadataProvider.GoodReads, BookMetadata.builder()
|
||||
.categories(Set.of("Fiction", "Drama"))
|
||||
.build());
|
||||
metadataMap.put(MetadataProvider.Google, BookMetadata.builder()
|
||||
.categories(Set.of("Literature", "Fiction"))
|
||||
.build());
|
||||
|
||||
BookMetadata result = metadataRefreshService.buildFetchMetadata(1L, mergeOptions, metadataMap);
|
||||
|
||||
assertNotNull(result.getCategories());
|
||||
Set<String> expectedCategories = Set.of("Fiction", "Drama", "Literature");
|
||||
|
||||
assertEquals(3, result.getCategories().size(), "Should have 3 unique categories when merging");
|
||||
assertTrue(result.getCategories().containsAll(expectedCategories), "Should contain all expected categories");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBookEntities_WithLibraryRefresh_ShouldReturnLibraryBooks() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.LIBRARY)
|
||||
.libraryId(1L)
|
||||
.build();
|
||||
|
||||
when(libraryRepository.findById(1L)).thenReturn(Optional.of(testLibrary));
|
||||
when(bookRepository.findBookIdsByLibraryId(1L)).thenReturn(Set.of(1L, 2L, 3L));
|
||||
|
||||
Set<Long> result = metadataRefreshService.getBookEntities(request);
|
||||
|
||||
assertEquals(Set.of(1L, 2L, 3L), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetBookEntities_WithBooksRefresh_ShouldReturnRequestedBooks() {
|
||||
MetadataRefreshRequest request = MetadataRefreshRequest.builder()
|
||||
.refreshType(MetadataRefreshRequest.RefreshType.BOOKS)
|
||||
.bookIds(Set.of(1L, 2L))
|
||||
.build();
|
||||
|
||||
Set<Long> result = metadataRefreshService.getBookEntities(request);
|
||||
|
||||
assertEquals(Set.of(1L, 2L), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateBookMetadata_ShouldCallUpdaterAndNotification() {
|
||||
BookMetadata metadata = BookMetadata.builder()
|
||||
.title("Updated Title")
|
||||
.build();
|
||||
Book book = Book.builder().id(1L).build();
|
||||
|
||||
when(bookMapper.toBook(testBook)).thenReturn(book);
|
||||
|
||||
metadataRefreshService.updateBookMetadata(testBook, metadata, true, false);
|
||||
|
||||
verify(bookMetadataUpdater).setBookMetadata(eq(testBook), any(MetadataUpdateWrapper.class), eq(true), eq(false));
|
||||
verify(notificationService).sendMessage(eq(Topic.BOOK_METADATA_UPDATE), eq(book));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ import {MagicShelf, MagicShelfService} from '../../../magic-shelf.service';
|
||||
import {BookRuleEvaluatorService} from '../../../book-rule-evaluator.service';
|
||||
import {GroupRule} from '../../../magic-shelf-component/magic-shelf-component';
|
||||
import {SidebarFilterTogglePrefService} from './filters/sidebar-filter-toggle-pref-service';
|
||||
import {MetadataRefreshRequest} from '../../../metadata/model/request/metadata-refresh-request.model';
|
||||
import {MetadataRefreshType} from '../../../metadata/model/request/metadata-refresh-type.enum';
|
||||
|
||||
export enum EntityType {
|
||||
LIBRARY = 'Library',
|
||||
@@ -153,7 +155,7 @@ export class BookBrowserComponent implements OnInit {
|
||||
|
||||
@ViewChild(BookTableComponent)
|
||||
bookTableComponent!: BookTableComponent;
|
||||
@ViewChild(BookFilterComponent, { static: false })
|
||||
@ViewChild(BookFilterComponent, {static: false})
|
||||
bookFilterComponent!: BookFilterComponent;
|
||||
|
||||
get currentCardSize() {
|
||||
@@ -211,9 +213,10 @@ export class BookBrowserComponent implements OnInit {
|
||||
});
|
||||
|
||||
this.metadataMenuItems = this.bookMenuService.getMetadataMenuItems(
|
||||
() => this.updateMetadata(),
|
||||
() => this.autoFetchMetadata(),
|
||||
() => this.fetchMetadata(),
|
||||
() => this.bulkEditMetadata(),
|
||||
() => this.multiBookEditMetadata()
|
||||
() => this.multiBookEditMetadata(),
|
||||
);
|
||||
this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks);
|
||||
|
||||
@@ -246,7 +249,7 @@ export class BookBrowserComponent implements OnInit {
|
||||
});
|
||||
|
||||
this.selectedFilter.next(parsedFilters);
|
||||
if(this.bookFilterComponent) {
|
||||
if (this.bookFilterComponent) {
|
||||
this.bookFilterComponent.setFilters?.(parsedFilters);
|
||||
this.bookFilterComponent.onFiltersChanged?.();
|
||||
}
|
||||
@@ -549,7 +552,15 @@ export class BookBrowserComponent implements OnInit {
|
||||
this.dynamicDialogRef = this.dialogHelperService.openLockUnlockMetadataDialog(this.selectedBooks);
|
||||
}
|
||||
|
||||
updateMetadata(): void {
|
||||
autoFetchMetadata(): void {
|
||||
const metadataRefreshRequest: MetadataRefreshRequest = {
|
||||
refreshType: MetadataRefreshType.BOOKS,
|
||||
bookIds: Array.from(this.selectedBooks),
|
||||
};
|
||||
this.bookService.autoRefreshMetadata(metadataRefreshRequest).subscribe();
|
||||
}
|
||||
|
||||
fetchMetadata(): void {
|
||||
this.dialogHelperService.openMetadataRefreshDialog(this.selectedBooks);
|
||||
}
|
||||
|
||||
@@ -695,9 +706,9 @@ export class BookBrowserComponent implements OnInit {
|
||||
map(filtered =>
|
||||
(filtered.loaded && !filtered.error)
|
||||
? ({
|
||||
...filtered,
|
||||
books: this.sortService.applySort(filtered.books || [], this.bookSorter.selectedSort!)
|
||||
})
|
||||
...filtered,
|
||||
books: this.sortService.applySort(filtered.books || [], this.bookSorter.selectedSort!)
|
||||
})
|
||||
: filtered
|
||||
)
|
||||
);
|
||||
|
||||
@@ -155,7 +155,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
const hasNoAlternativeFormats = !this.book.alternativeFormats || this.book.alternativeFormats.length === 0;
|
||||
const hasNoSupplementaryFiles = !this.book.supplementaryFiles || this.book.supplementaryFiles.length === 0;
|
||||
return (this.hasDownloadPermission() || this.hasDeleteBookPermission()) &&
|
||||
hasNoAlternativeFormats && hasNoSupplementaryFiles;
|
||||
hasNoAlternativeFormats && hasNoSupplementaryFiles;
|
||||
}
|
||||
|
||||
private initMenu() {
|
||||
@@ -196,7 +196,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
if (this.hasDownloadPermission()) {
|
||||
const hasAdditionalFiles = (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) ||
|
||||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
|
||||
if (hasAdditionalFiles) {
|
||||
const downloadItems = this.getDownloadMenuItems();
|
||||
@@ -226,7 +226,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
if (this.hasDeleteBookPermission()) {
|
||||
const hasAdditionalFiles = (this.book.alternativeFormats && this.book.alternativeFormats.length > 0) ||
|
||||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
|
||||
if (hasAdditionalFiles) {
|
||||
const deleteItems = this.getDeleteMenuItems();
|
||||
@@ -335,7 +335,6 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
icon: 'pi pi-bolt',
|
||||
command: () => {
|
||||
const metadataRefreshRequest: MetadataRefreshRequest = {
|
||||
quick: true,
|
||||
refreshType: MetadataRefreshType.BOOKS,
|
||||
bookIds: [this.book.id],
|
||||
};
|
||||
@@ -343,8 +342,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Advanced Fetch',
|
||||
icon: 'pi pi-database',
|
||||
label: 'Custom Fetch',
|
||||
icon: 'pi pi-sync',
|
||||
command: () => {
|
||||
this.dialogService.open(MetadataFetchOptionsComponent, {
|
||||
header: 'Metadata Refresh Options',
|
||||
@@ -518,7 +517,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
// Add separator if there are additional files
|
||||
if (this.hasAdditionalFiles()) {
|
||||
items.push({ separator: true });
|
||||
items.push({separator: true});
|
||||
}
|
||||
|
||||
// Add alternative formats
|
||||
@@ -535,8 +534,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
// Add separator if both alternative formats and supplementary files exist
|
||||
if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0 &&
|
||||
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
|
||||
items.push({ separator: true });
|
||||
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
|
||||
items.push({separator: true});
|
||||
}
|
||||
|
||||
// Add supplementary files
|
||||
@@ -578,7 +577,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
// Add separator if there are additional files
|
||||
if (this.hasAdditionalFiles()) {
|
||||
items.push({ separator: true });
|
||||
items.push({separator: true});
|
||||
}
|
||||
|
||||
// Add alternative formats
|
||||
@@ -595,8 +594,8 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
// Add separator if both alternative formats and supplementary files exist
|
||||
if (this.book.alternativeFormats && this.book.alternativeFormats.length > 0 &&
|
||||
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
|
||||
items.push({ separator: true });
|
||||
this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0) {
|
||||
items.push({separator: true});
|
||||
}
|
||||
|
||||
// Add supplementary files
|
||||
@@ -616,7 +615,7 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
private hasAdditionalFiles(): boolean {
|
||||
return !!(this.book.alternativeFormats && this.book.alternativeFormats.length > 0) ||
|
||||
!!(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
!!(this.book.supplementaryFiles && this.book.supplementaryFiles.length > 0);
|
||||
}
|
||||
|
||||
private downloadAdditionalFile(bookId: number, fileId: number): void {
|
||||
@@ -716,26 +715,26 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.toggleCardSelection(!this.isSelected)
|
||||
}
|
||||
|
||||
toggleCardSelection(selected: boolean):void {
|
||||
if (!this.isCheckboxEnabled) {
|
||||
return;
|
||||
}
|
||||
toggleCardSelection(selected: boolean): void {
|
||||
if (!this.isCheckboxEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSelected = selected;
|
||||
const shiftKey = this.lastMouseEvent?.shiftKey ?? false;
|
||||
this.isSelected = selected;
|
||||
const shiftKey = this.lastMouseEvent?.shiftKey ?? false;
|
||||
|
||||
this.checkboxClick.emit({
|
||||
index: this.index,
|
||||
bookId: this.book.id,
|
||||
selected: selected,
|
||||
shiftKey: shiftKey,
|
||||
});
|
||||
this.checkboxClick.emit({
|
||||
index: this.index,
|
||||
bookId: this.book.id,
|
||||
selected: selected,
|
||||
shiftKey: shiftKey,
|
||||
});
|
||||
|
||||
if (this.onBookSelect) {
|
||||
this.onBookSelect(this.book.id, selected);
|
||||
}
|
||||
if (this.onBookSelect) {
|
||||
this.onBookSelect(this.book.id, selected);
|
||||
}
|
||||
|
||||
this.lastMouseEvent = null;
|
||||
this.lastMouseEvent = null;
|
||||
}
|
||||
|
||||
toggleSelection(event: CheckboxChangeEvent): void {
|
||||
|
||||
@@ -16,12 +16,21 @@ export class BookMenuService {
|
||||
bookService = inject(BookService);
|
||||
|
||||
|
||||
getMetadataMenuItems(updateMetadata: () => void, bulkEditMetadata: () => void, multiBookEditMetadata: () => void): MenuItem[] {
|
||||
getMetadataMenuItems(
|
||||
autoFetchMetadata: () => void,
|
||||
fetchMetadata: () => void,
|
||||
bulkEditMetadata: () => void,
|
||||
multiBookEditMetadata: () => void): MenuItem[] {
|
||||
return [
|
||||
{
|
||||
label: 'Refresh Metadata',
|
||||
label: 'Auto Fetch Metadata',
|
||||
icon: 'pi pi-bolt',
|
||||
command: autoFetchMetadata
|
||||
},
|
||||
{
|
||||
label: 'Custom Fetch Metadata',
|
||||
icon: 'pi pi-sync',
|
||||
command: updateMetadata
|
||||
command: fetchMetadata
|
||||
},
|
||||
{
|
||||
label: 'Bulk Metadata Editor',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {ConfirmationService, MenuItem, MessageService} from 'primeng/api';
|
||||
import {Router} from '@angular/router';
|
||||
import {LibraryService} from './library.service';
|
||||
import {ShelfService} from './shelf.service';
|
||||
import {BookService} from './book.service';
|
||||
import {Library} from '../model/library.model';
|
||||
import {Shelf} from '../model/shelf.model';
|
||||
import {DialogService} from 'primeng/dynamicdialog';
|
||||
@@ -22,6 +23,7 @@ export class LibraryShelfMenuService {
|
||||
private messageService = inject(MessageService);
|
||||
private libraryService = inject(LibraryService);
|
||||
private shelfService = inject(ShelfService);
|
||||
private bookService = inject(BookService);
|
||||
private router = inject(Router);
|
||||
private dialogService = inject(DialogService);
|
||||
private magicShelfService = inject(MagicShelfService);
|
||||
@@ -116,8 +118,8 @@ export class LibraryShelfMenuService {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Refresh Books Metadata',
|
||||
icon: 'pi pi-database',
|
||||
label: 'Custom Fetch Metadata',
|
||||
icon: 'pi pi-sync',
|
||||
command: () => {
|
||||
this.dialogService.open(MetadataFetchOptionsComponent, {
|
||||
header: 'Metadata Refresh Options',
|
||||
@@ -129,6 +131,16 @@ export class LibraryShelfMenuService {
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Auto Fetch Metadata',
|
||||
icon: 'pi pi-bolt',
|
||||
command: () => {
|
||||
this.bookService.autoRefreshMetadata({
|
||||
refreshType: MetadataRefreshType.LIBRARY,
|
||||
libraryId: entity?.id || undefined
|
||||
}).subscribe();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ export interface KoboSettings {
|
||||
export interface AppSettings {
|
||||
autoBookSearch: boolean;
|
||||
similarBookRecommendation: boolean;
|
||||
metadataRefreshOptions: MetadataRefreshOptions;
|
||||
defaultMetadataRefreshOptions: MetadataRefreshOptions;
|
||||
libraryMetadataRefreshOptions: MetadataRefreshOptions[];
|
||||
uploadPattern: string;
|
||||
opdsServerEnabled: boolean;
|
||||
remoteAuthEnabled: boolean;
|
||||
@@ -127,6 +128,7 @@ export enum AppSettingKey {
|
||||
QUICK_BOOK_MATCH = 'QUICK_BOOK_MATCH',
|
||||
AUTO_BOOK_SEARCH = 'AUTO_BOOK_SEARCH',
|
||||
SIMILAR_BOOK_RECOMMENDATION = 'SIMILAR_BOOK_RECOMMENDATION',
|
||||
LIBRARY_METADATA_REFRESH_OPTIONS = 'LIBRARY_METADATA_REFRESH_OPTIONS',
|
||||
UPLOAD_FILE_PATTERN = 'UPLOAD_FILE_PATTERN',
|
||||
OPDS_SERVER_ENABLED = 'OPDS_SERVER_ENABLED',
|
||||
OIDC_ENABLED = 'OIDC_ENABLED',
|
||||
|
||||
@@ -7,46 +7,46 @@ import {
|
||||
OnInit,
|
||||
Output,
|
||||
} from "@angular/core";
|
||||
import { InputText } from "primeng/inputtext";
|
||||
import { Button } from "primeng/button";
|
||||
import { Divider } from "primeng/divider";
|
||||
import {InputText} from "primeng/inputtext";
|
||||
import {Button} from "primeng/button";
|
||||
import {Divider} from "primeng/divider";
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from "@angular/forms";
|
||||
import { Observable } from "rxjs";
|
||||
import { AsyncPipe } from "@angular/common";
|
||||
import { MessageService } from "primeng/api";
|
||||
import {Observable} from "rxjs";
|
||||
import {AsyncPipe} from "@angular/common";
|
||||
import {MessageService} from "primeng/api";
|
||||
import {
|
||||
Book,
|
||||
BookMetadata,
|
||||
MetadataClearFlags,
|
||||
MetadataUpdateWrapper,
|
||||
} from "../../../book/model/book.model";
|
||||
import { UrlHelperService } from "../../../utilities/service/url-helper.service";
|
||||
import {UrlHelperService} from "../../../utilities/service/url-helper.service";
|
||||
import {
|
||||
FileUpload,
|
||||
FileUploadErrorEvent,
|
||||
FileUploadEvent,
|
||||
} from "primeng/fileupload";
|
||||
import { HttpResponse } from "@angular/common/http";
|
||||
import { BookService } from "../../../book/service/book.service";
|
||||
import { ProgressSpinner } from "primeng/progressspinner";
|
||||
import { Tooltip } from "primeng/tooltip";
|
||||
import { filter, take } from "rxjs/operators";
|
||||
import { MetadataRestoreDialogComponent } from "../../../book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component";
|
||||
import { DialogService } from "primeng/dynamicdialog";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { MetadataRefreshRequest } from "../../model/request/metadata-refresh-request.model";
|
||||
import { MetadataRefreshType } from "../../model/request/metadata-refresh-type.enum";
|
||||
import { AutoComplete } from "primeng/autocomplete";
|
||||
import { Textarea } from "primeng/textarea";
|
||||
import { IftaLabel } from "primeng/iftalabel";
|
||||
import { CoverSearchComponent } from "../../cover-search/cover-search.component";
|
||||
import { Image } from "primeng/image";
|
||||
import { LazyLoadImageModule } from "ng-lazyload-image";
|
||||
import {HttpResponse} from "@angular/common/http";
|
||||
import {BookService} from "../../../book/service/book.service";
|
||||
import {ProgressSpinner} from "primeng/progressspinner";
|
||||
import {Tooltip} from "primeng/tooltip";
|
||||
import {filter, take} from "rxjs/operators";
|
||||
import {MetadataRestoreDialogComponent} from "../../../book/components/book-browser/metadata-restore-dialog-component/metadata-restore-dialog-component";
|
||||
import {DialogService} from "primeng/dynamicdialog";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {MetadataRefreshRequest} from "../../model/request/metadata-refresh-request.model";
|
||||
import {MetadataRefreshType} from "../../model/request/metadata-refresh-type.enum";
|
||||
import {AutoComplete} from "primeng/autocomplete";
|
||||
import {Textarea} from "primeng/textarea";
|
||||
import {IftaLabel} from "primeng/iftalabel";
|
||||
import {CoverSearchComponent} from "../../cover-search/cover-search.component";
|
||||
import {Image} from "primeng/image";
|
||||
import {LazyLoadImageModule} from "ng-lazyload-image";
|
||||
|
||||
@Component({
|
||||
selector: "app-metadata-editor",
|
||||
@@ -270,36 +270,36 @@ export class MetadataEditorComponent implements OnInit {
|
||||
});
|
||||
|
||||
const lockableFields: { key: keyof BookMetadata; control: string }[] = [
|
||||
{ key: "titleLocked", control: "title" },
|
||||
{ key: "subtitleLocked", control: "subtitle" },
|
||||
{ key: "authorsLocked", control: "authors" },
|
||||
{ key: "categoriesLocked", control: "categories" },
|
||||
{ key: "publisherLocked", control: "publisher" },
|
||||
{ key: "publishedDateLocked", control: "publishedDate" },
|
||||
{ key: "languageLocked", control: "language" },
|
||||
{ key: "isbn10Locked", control: "isbn10" },
|
||||
{ key: "isbn13Locked", control: "isbn13" },
|
||||
{ key: "asinLocked", control: "asin" },
|
||||
{ key: "amazonReviewCountLocked", control: "amazonReviewCount" },
|
||||
{ key: "amazonRatingLocked", control: "amazonRating" },
|
||||
{ key: "personalRatingLocked", control: "personalRating" },
|
||||
{ key: "goodreadsIdLocked", control: "goodreadsId" },
|
||||
{ key: "comicvineIdLocked", control: "comicvineId" },
|
||||
{ key: "goodreadsReviewCountLocked", control: "goodreadsReviewCount" },
|
||||
{ key: "goodreadsRatingLocked", control: "goodreadsRating" },
|
||||
{ key: "hardcoverIdLocked", control: "hardcoverId" },
|
||||
{ key: "hardcoverReviewCountLocked", control: "hardcoverReviewCount" },
|
||||
{ key: "hardcoverRatingLocked", control: "hardcoverRating" },
|
||||
{ key: "googleIdLocked", control: "googleId" },
|
||||
{ key: "pageCountLocked", control: "pageCount" },
|
||||
{ key: "descriptionLocked", control: "description" },
|
||||
{ key: "seriesNameLocked", control: "seriesName" },
|
||||
{ key: "seriesNumberLocked", control: "seriesNumber" },
|
||||
{ key: "seriesTotalLocked", control: "seriesTotal" },
|
||||
{ key: "coverLocked", control: "thumbnailUrl" },
|
||||
{key: "titleLocked", control: "title"},
|
||||
{key: "subtitleLocked", control: "subtitle"},
|
||||
{key: "authorsLocked", control: "authors"},
|
||||
{key: "categoriesLocked", control: "categories"},
|
||||
{key: "publisherLocked", control: "publisher"},
|
||||
{key: "publishedDateLocked", control: "publishedDate"},
|
||||
{key: "languageLocked", control: "language"},
|
||||
{key: "isbn10Locked", control: "isbn10"},
|
||||
{key: "isbn13Locked", control: "isbn13"},
|
||||
{key: "asinLocked", control: "asin"},
|
||||
{key: "amazonReviewCountLocked", control: "amazonReviewCount"},
|
||||
{key: "amazonRatingLocked", control: "amazonRating"},
|
||||
{key: "personalRatingLocked", control: "personalRating"},
|
||||
{key: "goodreadsIdLocked", control: "goodreadsId"},
|
||||
{key: "comicvineIdLocked", control: "comicvineId"},
|
||||
{key: "goodreadsReviewCountLocked", control: "goodreadsReviewCount"},
|
||||
{key: "goodreadsRatingLocked", control: "goodreadsRating"},
|
||||
{key: "hardcoverIdLocked", control: "hardcoverId"},
|
||||
{key: "hardcoverReviewCountLocked", control: "hardcoverReviewCount"},
|
||||
{key: "hardcoverRatingLocked", control: "hardcoverRating"},
|
||||
{key: "googleIdLocked", control: "googleId"},
|
||||
{key: "pageCountLocked", control: "pageCount"},
|
||||
{key: "descriptionLocked", control: "description"},
|
||||
{key: "seriesNameLocked", control: "seriesName"},
|
||||
{key: "seriesNumberLocked", control: "seriesNumber"},
|
||||
{key: "seriesTotalLocked", control: "seriesTotal"},
|
||||
{key: "coverLocked", control: "thumbnailUrl"},
|
||||
];
|
||||
|
||||
for (const { key, control } of lockableFields) {
|
||||
for (const {key, control} of lockableFields) {
|
||||
const isLocked = metadata[key] === true;
|
||||
const formControl = this.metadataForm.get(control);
|
||||
if (formControl) {
|
||||
@@ -512,7 +512,7 @@ export class MetadataEditorComponent implements OnInit {
|
||||
cover: false,
|
||||
};
|
||||
|
||||
return { metadata, clearFlags };
|
||||
return {metadata, clearFlags};
|
||||
}
|
||||
|
||||
private updateMetadata(shouldLockAllFields: boolean | undefined): void {
|
||||
@@ -616,7 +616,7 @@ export class MetadataEditorComponent implements OnInit {
|
||||
this.bookService.getComicInfoMetadata(this.currentBookId).subscribe({
|
||||
next: (metadata) => {
|
||||
console.log("Retrieved ComicInfo.xml metadata:", metadata);
|
||||
|
||||
|
||||
if (metadata) {
|
||||
this.originalMetadata = structuredClone(metadata);
|
||||
this.populateFormFromMetadata(metadata);
|
||||
@@ -662,7 +662,6 @@ export class MetadataEditorComponent implements OnInit {
|
||||
this.refreshingBookIds.add(bookId);
|
||||
this.isAutoFetching = true;
|
||||
const request: MetadataRefreshRequest = {
|
||||
quick: true,
|
||||
refreshType: MetadataRefreshType.BOOKS,
|
||||
bookIds: [bookId],
|
||||
};
|
||||
|
||||
@@ -464,7 +464,7 @@
|
||||
@if (userState.user!.permissions.canEditMetadata || userState.user!.permissions.admin) {
|
||||
@if (refreshMenuItems$ | async; as refreshItems) {
|
||||
<p-splitbutton
|
||||
[label]="isAutoFetching ? 'Fetching...' : 'Fetch Metadata'"
|
||||
[label]="isAutoFetching ? 'Fetching...' : 'Auto Fetch'"
|
||||
[icon]="isAutoFetching ? 'pi pi-spin pi-spinner' : 'pi pi-bolt'"
|
||||
[outlined]="true"
|
||||
[model]="refreshItems"
|
||||
|
||||
@@ -115,8 +115,8 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
filter((book): book is Book => book !== null),
|
||||
map((book): MenuItem[] => [
|
||||
{
|
||||
label: 'Advanced Fetch',
|
||||
icon: 'pi pi-database',
|
||||
label: 'Custom Fetch',
|
||||
icon: 'pi pi-sync',
|
||||
command: () => {
|
||||
this.dialogService.open(MetadataFetchOptionsComponent, {
|
||||
header: 'Metadata Refresh Options',
|
||||
@@ -393,7 +393,6 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
quickRefresh(bookId: number) {
|
||||
this.isAutoFetching = true;
|
||||
const request: MetadataRefreshRequest = {
|
||||
quick: true,
|
||||
refreshType: MetadataRefreshType.BOOKS,
|
||||
bookIds: [bookId],
|
||||
};
|
||||
|
||||
@@ -3,16 +3,41 @@
|
||||
<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">4th Priority</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">3rd Priority</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">2nd Priority</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-300">1st Priority</th>
|
||||
<th class="px-4 py-1.5 text-left font-semibold text-gray-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">
|
||||
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">
|
||||
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">
|
||||
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</td>
|
||||
<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')"
|
||||
|
||||
@@ -47,6 +47,8 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
|
||||
private messageService = inject(MessageService);
|
||||
|
||||
private justSubmitted = false;
|
||||
|
||||
private initializeFieldOptions(): FieldOptions {
|
||||
return this.fields.reduce((acc, field) => {
|
||||
acc[field] = {p1: null, p2: null, p3: null, p4: null};
|
||||
@@ -55,7 +57,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['currentMetadataOptions'] && this.currentMetadataOptions) {
|
||||
if (changes['currentMetadataOptions'] && this.currentMetadataOptions && !this.justSubmitted) {
|
||||
this.refreshCovers = this.currentMetadataOptions.refreshCovers || false;
|
||||
this.mergeCategories = this.currentMetadataOptions.mergeCategories || false;
|
||||
this.reviewBeforeApply = this.currentMetadataOptions.reviewBeforeApply || false;
|
||||
@@ -89,7 +91,10 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
);
|
||||
|
||||
if (allFieldsHaveProvider) {
|
||||
this.justSubmitted = true;
|
||||
|
||||
const metadataRefreshOptions: MetadataRefreshOptions = {
|
||||
libraryId: null,
|
||||
allP1: this.allP1.value,
|
||||
allP2: this.allP2.value,
|
||||
allP3: this.allP3.value,
|
||||
@@ -99,7 +104,12 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
reviewBeforeApply: this.reviewBeforeApply,
|
||||
fieldOptions: this.fieldOptions
|
||||
};
|
||||
|
||||
this.metadataOptionsSubmitted.emit(metadataRefreshOptions);
|
||||
|
||||
setTimeout(() => {
|
||||
this.justSubmitted = false;
|
||||
}, 1000);
|
||||
} else {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
@@ -111,6 +121,7 @@ export class MetadataAdvancedFetchOptionsComponent implements OnChanges {
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.justSubmitted = false;
|
||||
this.allP1.value = null;
|
||||
this.allP2.value = null;
|
||||
this.allP3.value = null;
|
||||
|
||||
@@ -36,7 +36,7 @@ export class MetadataFetchOptionsComponent {
|
||||
filter(settings => settings != null),
|
||||
take(1)
|
||||
).subscribe(settings => {
|
||||
this.currentMetadataOptions = settings?.metadataRefreshOptions;
|
||||
this.currentMetadataOptions = settings?.defaultMetadataRefreshOptions;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface MetadataRefreshOptions {
|
||||
libraryId: number | null;
|
||||
allP4: string | null;
|
||||
allP3: string | null;
|
||||
allP2: string | null;
|
||||
|
||||
@@ -2,7 +2,6 @@ import {MetadataRefreshType} from './metadata-refresh-type.enum';
|
||||
import {MetadataRefreshOptions} from './metadata-refresh-options.model';
|
||||
|
||||
export interface MetadataRefreshRequest {
|
||||
quick?: boolean;
|
||||
refreshType: MetadataRefreshType;
|
||||
libraryId?: number;
|
||||
bookIds?: number[];
|
||||
|
||||
@@ -40,7 +40,7 @@ export class MultiBookMetadataFetchComponent implements OnInit, OnDestroy {
|
||||
this.appSettingsService.appSettings$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(settings => {
|
||||
this.currentMetadataOptions = settings!.metadataRefreshOptions;
|
||||
this.currentMetadataOptions = settings!.defaultMetadataRefreshOptions;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<div class="main-container enclosing-container">
|
||||
<div class="settings-header">
|
||||
<h2 class="settings-title">
|
||||
<i class="pi pi-database"></i>
|
||||
Library Metadata Configuration
|
||||
</h2>
|
||||
<p class="settings-description">
|
||||
Configure metadata fetch behavior for your libraries. Set global defaults and create library-specific overrides to control how book metadata is retrieved from different providers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-globe"></i>
|
||||
Default Metadata Settings
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
Set global default provider priorities for book metadata fields. These settings apply to all libraries unless overridden below.
|
||||
BookLore tries providers from left to right (1st → 2nd → 3rd → 4th priority) for each book field until it finds data.
|
||||
The system checks your 1st priority provider first - if that provider doesn't have the specific field (like description or author),
|
||||
it automatically moves to your 2nd priority, then 3rd, and finally 4th. Leave a priority empty to skip it entirely.
|
||||
For example, if Amazon (1st) has no description but Google Books (2nd) does, Google's description will be used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<app-metadata-advanced-fetch-options
|
||||
[currentMetadataOptions]="defaultMetadataOptions"
|
||||
[submitButtonLabel]="'Save Default Settings'"
|
||||
(metadataOptionsSubmitted)="onDefaultMetadataOptionsSubmitted($event)">
|
||||
</app-metadata-advanced-fetch-options>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-book"></i>
|
||||
Library-Specific Overrides
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
Override the default priority settings for specific libraries. For example, you might prefer Amazon for fiction but Google Books for technical books. Each library can have its own provider priority order while falling back to defaults for unspecified fields.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
@if (libraries$ | async; as libraries) {
|
||||
<p-accordion>
|
||||
@for (library of libraries; track trackByLibrary($index, library); let i = $index) {
|
||||
<p-accordion-panel [value]="i">
|
||||
<p-accordion-header>
|
||||
<div class="accordion-header-content">
|
||||
<div class="library-info">
|
||||
<i [class]="'pi pi-' + library.icon" class="library-icon"></i>
|
||||
<span class="library-name">{{ library.name }}</span>
|
||||
<span class="dot-separator">•</span>
|
||||
@if (hasLibraryOverride(library.id!)) {
|
||||
<span class="override-indicator custom">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
Custom Settings
|
||||
</span>
|
||||
} @else {
|
||||
<span class="default-indicator default">
|
||||
<i class="pi pi-globe"></i>
|
||||
Default Settings
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div class="accordion-content-wrapper">
|
||||
<app-metadata-advanced-fetch-options
|
||||
[currentMetadataOptions]="getLibraryOptions(library.id!)"
|
||||
[submitButtonLabel]="'Save ' + library.name + ' Settings'"
|
||||
(metadataOptionsSubmitted)="onLibraryMetadataOptionsSubmitted(library.id!, $event)">
|
||||
</app-metadata-advanced-fetch-options>
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
}
|
||||
</p-accordion>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,235 @@
|
||||
.main-container {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
height: calc(100dvh - 10.5rem);
|
||||
overflow-y: auto;
|
||||
border-width: 1px;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
height: calc(100dvh - 11.65rem);
|
||||
}
|
||||
}
|
||||
|
||||
.enclosing-container {
|
||||
border-color: var(--p-content-border-color);
|
||||
background: var(--p-content-background);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--p-text-color);
|
||||
margin: 0 0 0.75rem 0;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
color: var(--p-text-muted-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.preferences-section {
|
||||
@media (min-width: 768px) {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.section-description {
|
||||
color: var(--p-text-muted-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0.5rem 0 0 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
margin-top: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
margin: 0 0 0.5rem 0;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--p-content-background);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
app-metadata-advanced-fetch-options {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
::ng-deep {
|
||||
> div,
|
||||
> .container,
|
||||
> .main-container {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
.p-accordion {
|
||||
.p-accordion-panel {
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordion-header {
|
||||
background: var(--p-surface-100);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--p-surface-200);
|
||||
}
|
||||
|
||||
.p-accordion-header-content {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordion-content {
|
||||
border: none;
|
||||
border-top: 1px solid var(--p-content-border-color);
|
||||
|
||||
.p-accordion-content-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.library-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.library-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.library-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dot-separator {
|
||||
color: var(--p-text-muted-color);
|
||||
font-weight: bold;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.override-indicator,
|
||||
.default-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.custom {
|
||||
color: var(--p-green-500);
|
||||
}
|
||||
|
||||
&.default {
|
||||
color: var(--p-blue-500);
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-content-wrapper {
|
||||
padding: 1.5rem;
|
||||
|
||||
app-metadata-advanced-fetch-options {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
::ng-deep {
|
||||
> div,
|
||||
> .container,
|
||||
> .main-container {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Observable} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {AccordionModule} from 'primeng/accordion';
|
||||
import {MessageService} from 'primeng/api';
|
||||
|
||||
import {Library} from '../../book/model/library.model';
|
||||
import {LibraryService} from '../../book/service/library.service';
|
||||
import {MetadataRefreshOptions} from '../../metadata/model/request/metadata-refresh-options.model';
|
||||
import {MetadataAdvancedFetchOptionsComponent} from '../../metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component';
|
||||
import {AppSettings, AppSettingKey} from '../../core/model/app-settings.model';
|
||||
import {AppSettingsService} from '../../core/service/app-settings.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-metadata-settings-component',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, MetadataAdvancedFetchOptionsComponent, AccordionModule],
|
||||
templateUrl: './library-metadata-settings.component.html',
|
||||
styleUrls: ['./library-metadata-settings.component.scss']
|
||||
})
|
||||
export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
private libraryService = inject(LibraryService);
|
||||
private appSettingsService = inject(AppSettingsService);
|
||||
private messageService = inject(MessageService);
|
||||
|
||||
libraries$: Observable<Library[]> = this.libraryService.libraryState$.pipe(
|
||||
map(state => state.libraries || [])
|
||||
);
|
||||
|
||||
defaultMetadataOptions: MetadataRefreshOptions = this.getDefaultMetadataOptions();
|
||||
libraryMetadataOptions: { [libraryId: number]: MetadataRefreshOptions } = {};
|
||||
|
||||
ngOnInit() {
|
||||
this.appSettingsService.appSettings$.subscribe(appSettings => {
|
||||
if (appSettings) {
|
||||
this.defaultMetadataOptions = appSettings.defaultMetadataRefreshOptions;
|
||||
this.initializeLibraryOptions(appSettings);
|
||||
this.updateLibraryOptionsFromSettings(appSettings);
|
||||
}
|
||||
});
|
||||
|
||||
this.libraries$.subscribe(libraries => {
|
||||
libraries.forEach(library => {
|
||||
if (library.id && !this.libraryMetadataOptions[library.id]) {
|
||||
const libraryOptions = this.getLibrarySpecificOptions(library.id);
|
||||
if (libraryOptions) {
|
||||
this.libraryMetadataOptions[library.id] = libraryOptions;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onDefaultMetadataOptionsSubmitted(options: MetadataRefreshOptions) {
|
||||
this.defaultMetadataOptions = options;
|
||||
this.saveDefaultMetadataOptions(options);
|
||||
}
|
||||
|
||||
onLibraryMetadataOptionsSubmitted(libraryId: number, options: MetadataRefreshOptions) {
|
||||
this.libraryMetadataOptions[libraryId] = {...options, libraryId};
|
||||
this.saveLibraryMetadataOptions();
|
||||
}
|
||||
|
||||
hasLibraryOverride(libraryId: number): boolean {
|
||||
return libraryId in this.libraryMetadataOptions;
|
||||
}
|
||||
|
||||
getLibraryOptions(libraryId: number): MetadataRefreshOptions {
|
||||
return this.libraryMetadataOptions[libraryId] || {...this.defaultMetadataOptions, libraryId};
|
||||
}
|
||||
|
||||
trackByLibrary(index: number, library: Library): number | undefined {
|
||||
return library.id;
|
||||
}
|
||||
|
||||
private saveDefaultMetadataOptions(options: MetadataRefreshOptions) {
|
||||
const settingsToSave = [
|
||||
{
|
||||
key: AppSettingKey.QUICK_BOOK_MATCH,
|
||||
newValue: options
|
||||
}
|
||||
];
|
||||
|
||||
this.appSettingsService.saveSettings(settingsToSave).subscribe({
|
||||
next: () => {
|
||||
this.showMessage('success', 'Settings Saved', 'Default metadata options have been saved successfully.');
|
||||
this.updateLibrariesUsingDefaults();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error saving default metadata options:', error);
|
||||
this.showMessage('error', 'Save Failed', 'Failed to save default metadata options. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private saveLibraryMetadataOptions() {
|
||||
const libraryOptionsArray = Object.values(this.libraryMetadataOptions).filter(option =>
|
||||
option.libraryId !== null && option.libraryId !== undefined
|
||||
);
|
||||
|
||||
const settingsToSave = [
|
||||
{
|
||||
key: AppSettingKey.LIBRARY_METADATA_REFRESH_OPTIONS,
|
||||
newValue: libraryOptionsArray
|
||||
}
|
||||
];
|
||||
|
||||
this.appSettingsService.saveSettings(settingsToSave).subscribe({
|
||||
next: () => {
|
||||
this.showMessage('success', 'Settings Saved', 'Library metadata options have been saved successfully.');
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error saving library metadata options:', error);
|
||||
this.showMessage('error', 'Save Failed', 'Failed to save library metadata options. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateLibrariesUsingDefaults() {
|
||||
Object.keys(this.libraryMetadataOptions).forEach(libraryIdStr => {
|
||||
const libraryId = parseInt(libraryIdStr, 10);
|
||||
if (!this.hasLibrarySpecificOptionsInSettings(libraryId)) {
|
||||
delete this.libraryMetadataOptions[libraryId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showMessage(severity: 'success' | 'error', summary: string, detail: string) {
|
||||
this.messageService.add({
|
||||
severity,
|
||||
summary,
|
||||
detail,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
|
||||
private initializeLibraryOptions(appSettings: AppSettings) {
|
||||
if (appSettings?.libraryMetadataRefreshOptions) {
|
||||
appSettings.libraryMetadataRefreshOptions.forEach(option => {
|
||||
if (option.libraryId) {
|
||||
this.libraryMetadataOptions[option.libraryId] = option;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateLibraryOptionsFromSettings(appSettings: AppSettings) {
|
||||
Object.keys(this.libraryMetadataOptions).forEach(libraryIdStr => {
|
||||
const libraryId = parseInt(libraryIdStr, 10);
|
||||
if (!this.hasLibrarySpecificOptions(libraryId)) {
|
||||
this.libraryMetadataOptions[libraryId] = {...this.defaultMetadataOptions};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private hasLibrarySpecificOptions(libraryId: number): boolean {
|
||||
return libraryId in this.libraryMetadataOptions;
|
||||
}
|
||||
|
||||
private hasLibrarySpecificOptionsInSettings(libraryId: number): boolean {
|
||||
let hasOptions = false;
|
||||
this.appSettingsService.appSettings$.subscribe(settings => {
|
||||
hasOptions = settings?.libraryMetadataRefreshOptions?.some(
|
||||
option => option.libraryId === libraryId
|
||||
) || false;
|
||||
}).unsubscribe();
|
||||
|
||||
return hasOptions;
|
||||
}
|
||||
|
||||
private getLibrarySpecificOptions(libraryId: number): MetadataRefreshOptions | null {
|
||||
let libraryOptions: MetadataRefreshOptions | null = null;
|
||||
this.appSettingsService.appSettings$.subscribe(settings => {
|
||||
libraryOptions = settings?.libraryMetadataRefreshOptions?.find(
|
||||
option => option.libraryId === libraryId
|
||||
) || null;
|
||||
}).unsubscribe();
|
||||
|
||||
return libraryOptions;
|
||||
}
|
||||
|
||||
private getDefaultMetadataOptions(): MetadataRefreshOptions {
|
||||
return {
|
||||
libraryId: null,
|
||||
allP1: null,
|
||||
allP2: null,
|
||||
allP3: null,
|
||||
allP4: null,
|
||||
refreshCovers: false,
|
||||
mergeCategories: false,
|
||||
reviewBeforeApply: false,
|
||||
fieldOptions: {
|
||||
title: {p1: null, p2: null, p3: null, p4: null},
|
||||
subtitle: {p1: null, p2: null, p3: null, p4: null},
|
||||
description: {p1: null, p2: null, p3: null, p4: null},
|
||||
authors: {p1: null, p2: null, p3: null, p4: null},
|
||||
publisher: {p1: null, p2: null, p3: null, p4: null},
|
||||
publishedDate: {p1: null, p2: null, p3: null, p4: null},
|
||||
seriesName: {p1: null, p2: null, p3: null, p4: null},
|
||||
seriesNumber: {p1: null, p2: null, p3: null, p4: null},
|
||||
seriesTotal: {p1: null, p2: null, p3: null, p4: null},
|
||||
isbn13: {p1: null, p2: null, p3: null, p4: null},
|
||||
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}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="main-container enclosing-container">
|
||||
<div class="settings-header">
|
||||
<h2 class="settings-title">
|
||||
<i class="pi pi-database"></i>
|
||||
<i class="pi pi-sliders-h"></i>
|
||||
Metadata Settings
|
||||
</h2>
|
||||
<p class="settings-description">
|
||||
@@ -67,26 +67,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-search"></i>
|
||||
Quick Book Match Preferences
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
Choose how metadata fields (title, description, authors, categories, cover) are retrieved by setting priority providers. You can apply the same provider settings to all fields or customize them individually. Enable 'Refresh Covers' to update book covers, and 'Merge Categories' to consolidate categories/genres from all sources while preserving the existing categories/genres in the book.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<app-metadata-advanced-fetch-options
|
||||
(metadataOptionsSubmitted)="onMetadataSubmit($event)"
|
||||
[currentMetadataOptions]="currentMetadataOptions"
|
||||
[submitButtonLabel]="'Save'">
|
||||
</app-metadata-advanced-fetch-options>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preferences-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {MetadataAdvancedFetchOptionsComponent} from '../../metadata/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component';
|
||||
import {MetadataProviderSettingsComponent} from '../global-preferences/metadata-provider-settings/metadata-provider-settings.component';
|
||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {MetadataRefreshOptions} from '../../metadata/model/request/metadata-refresh-options.model';
|
||||
@@ -12,12 +11,12 @@ import {MetadataMatchWeightsComponent} from '../global-preferences/metadata-matc
|
||||
import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
import {MetadataPersistenceSettingsComponent} from './metadata-persistence-settings-component/metadata-persistence-settings-component';
|
||||
import {PublicReviewsSettingsComponent} from './public-reviews-settings-component/public-reviews-settings-component';
|
||||
import {LibraryMetadataSettingsComponent} from '../library-metadata-settings-component/library-metadata-settings.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-settings-component',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MetadataAdvancedFetchOptionsComponent,
|
||||
MetadataProviderSettingsComponent,
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
@@ -67,8 +66,8 @@ export class MetadataSettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
private initializeSettings(settings: AppSettings): void {
|
||||
if (settings.metadataRefreshOptions) {
|
||||
this.currentMetadataOptions = settings.metadataRefreshOptions;
|
||||
if (settings.defaultMetadataRefreshOptions) {
|
||||
this.currentMetadataOptions = settings.defaultMetadataRefreshOptions;
|
||||
}
|
||||
|
||||
this.metadataDownloadOnBookdrop = settings.metadataDownloadOnBookdrop ?? true;
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
</p-tab>
|
||||
@if (userState.user.permissions.admin) {
|
||||
<p-tab [value]="SettingsTab.MetadataSettings">
|
||||
<i class="pi pi-sliders-h"></i> Metadata
|
||||
<i class="pi pi-sliders-h"></i> Metadata 1
|
||||
</p-tab>
|
||||
<p-tab [value]="SettingsTab.LibraryMetadataSettings">
|
||||
<i class="pi pi-database"></i> Metadata 2
|
||||
</p-tab>
|
||||
<p-tab [value]="SettingsTab.ApplicationSettings">
|
||||
<i class="pi pi-cog"></i> Application
|
||||
@@ -47,6 +50,9 @@
|
||||
<p-tabpanel [value]="SettingsTab.MetadataSettings">
|
||||
<app-metadata-settings-component></app-metadata-settings-component>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel [value]="SettingsTab.LibraryMetadataSettings">
|
||||
<app-library-metadata-settings-component></app-library-metadata-settings-component>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel [value]="SettingsTab.ApplicationSettings">
|
||||
<app-global-preferences></app-global-preferences>
|
||||
</p-tabpanel>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {MetadataSettingsComponent} from './metadata-settings-component/metadata-
|
||||
import {DeviceSettingsComponent} from './device-settings-component/device-settings-component';
|
||||
import {FileNamingPatternComponent} from './file-naming-pattern/file-naming-pattern.component';
|
||||
import {OpdsSettingsV2} from './opds-settings-v2/opds-settings-v2';
|
||||
import {LibraryMetadataSettingsComponent} from './library-metadata-settings-component/library-metadata-settings.component';
|
||||
|
||||
export enum SettingsTab {
|
||||
ReaderSettings = 'reader',
|
||||
@@ -23,6 +24,7 @@ export enum SettingsTab {
|
||||
EmailSettings = 'email',
|
||||
NamingPattern = 'naming-pattern',
|
||||
MetadataSettings = 'metadata',
|
||||
LibraryMetadataSettings = 'metadata-library',
|
||||
ApplicationSettings = 'application',
|
||||
AuthenticationSettings = 'authentication',
|
||||
OpdsV2 = 'opds'
|
||||
@@ -46,7 +48,8 @@ export enum SettingsTab {
|
||||
MetadataSettingsComponent,
|
||||
DeviceSettingsComponent,
|
||||
FileNamingPatternComponent,
|
||||
OpdsSettingsV2
|
||||
OpdsSettingsV2,
|
||||
LibraryMetadataSettingsComponent
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss'
|
||||
|
||||
Reference in New Issue
Block a user