Introduce per-library settings for metadata fetching (#1239)

This commit is contained in:
Aditya Chandel
2025-09-30 00:07:59 -06:00
committed by GitHub
parent 2dcfe00ddc
commit 131b19f49f
31 changed files with 1355 additions and 177 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),

View File

@@ -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;

View File

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

View File

@@ -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,

View File

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

View File

@@ -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;

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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',

View File

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

View File

@@ -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',

View File

@@ -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],
};

View File

@@ -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"

View File

@@ -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],
};

View File

@@ -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')"

View File

@@ -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;

View File

@@ -36,7 +36,7 @@ export class MetadataFetchOptionsComponent {
filter(settings => settings != null),
take(1)
).subscribe(settings => {
this.currentMetadataOptions = settings?.metadataRefreshOptions;
this.currentMetadataOptions = settings?.defaultMetadataRefreshOptions;
});
}

View File

@@ -1,4 +1,5 @@
export interface MetadataRefreshOptions {
libraryId: number | null;
allP4: string | null;
allP3: string | null;
allP2: string | null;

View File

@@ -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[];

View File

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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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">

View File

@@ -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;

View File

@@ -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>

View File

@@ -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'