WIP: Metadata v2 refactor almost complete

This commit is contained in:
aditya.chandel
2025-01-12 13:04:03 -07:00
parent 25e9dd6b63
commit 7728cebcf9
30 changed files with 268 additions and 391 deletions

View File

@@ -0,0 +1,24 @@
package com.adityachandel.booklore.config;
import com.adityachandel.booklore.service.metadata.model.MetadataProvider;
import com.adityachandel.booklore.service.metadata.parser.AmazonBookParser;
import com.adityachandel.booklore.service.metadata.parser.BookParser;
import com.adityachandel.booklore.service.metadata.parser.GoodReadsParser;
import com.adityachandel.booklore.service.metadata.parser.GoogleParser;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
@Configuration
public class BookParserConfig {
@Bean
public Map<MetadataProvider, BookParser> parserMap(GoogleParser googleParser, AmazonBookParser amazonBookParser, GoodReadsParser goodReadsParser) {
return Map.of(
MetadataProvider.Amazon, amazonBookParser,
MetadataProvider.GoodReads, goodReadsParser,
MetadataProvider.Google, googleParser
);
}
}

View File

@@ -13,6 +13,7 @@ import com.adityachandel.booklore.service.metadata.model.FetchedBookMetadata;
import com.adityachandel.booklore.service.metadata.model.MetadataProvider;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -29,7 +30,7 @@ public class MetadataController {
@PostMapping("/{bookId}")
public ResponseEntity<List<FetchedBookMetadata>> getBookMetadata(@RequestBody(required = false) FetchMetadataRequest fetchMetadataRequest, @PathVariable Long bookId) {
return ResponseEntity.ok(bookMetadataService.fetchMetadataList(bookId, fetchMetadataRequest));
return ResponseEntity.ok(bookMetadataService.fetchMetadataForRequest(bookId, fetchMetadataRequest));
}
@PutMapping("/{bookId}")
@@ -44,22 +45,9 @@ public class MetadataController {
return ResponseEntity.ok(bookMetadata);
}
@PutMapping(path = "/library/{libraryId}/refresh")
public ResponseEntity<String> scheduleRefresh(@PathVariable Long libraryId, @RequestBody LibraryMetadataRefreshRequest request) {
jobSchedulerService.scheduleMetadataRefresh(request);
return ResponseEntity.noContent().build();
}
@PutMapping(path = "/refreshV2")
public ResponseEntity<String> scheduleRefreshV2(@RequestBody MetadataRefreshRequest request) {
public ResponseEntity<String> scheduleRefreshV2(@Validated @RequestBody MetadataRefreshRequest request) {
jobSchedulerService.scheduleMetadataRefreshV2(request);
return ResponseEntity.noContent().build();
}
@PutMapping(path = "/books/refresh")
public ResponseEntity<String> scheduleBookMetadataRefresh(@RequestBody BooksMetadataRefreshRequest request) {
jobSchedulerService.scheduleBookMetadataRefresh(request);
return ResponseEntity.noContent().build();
}
}

View File

@@ -21,7 +21,8 @@ public enum ApiError {
SHELF_NOT_FOUND(HttpStatus.NOT_FOUND, "Shelf not found with ID: %d"),
SCHEDULE_REFRESH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to schedule metadata refresh job. Error: %s"),
ANOTHER_METADATA_JOB_RUNNING(HttpStatus.CONFLICT, "A metadata refresh job is currently running. Please wait for it to complete before initiating a new one."),
METADATA_SOURCE_NOT_IMPLEMENT_OR_DOES_NOT_EXIST(HttpStatus.BAD_REQUEST, "Metadata source not implement or does not exist");
METADATA_SOURCE_NOT_IMPLEMENT_OR_DOES_NOT_EXIST(HttpStatus.BAD_REQUEST, "Metadata source not implement or does not exist"),
INVALID_REFRESH_TYPE(HttpStatus.BAD_REQUEST, "The refresh type is invalid"),;
private final HttpStatus status;
private final String message;

View File

@@ -2,6 +2,7 @@ package com.adityachandel.booklore.model.dto.request;
import com.adityachandel.booklore.service.metadata.model.MetadataProvider;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
@@ -12,8 +13,9 @@ import lombok.AllArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
public class MetadataRefreshOptions {
@NotNull(message = "Default Provider cannot be null")
private MetadataProvider defaultProvider;
private boolean refreshCovers;
private FieldOptions fieldOptions;

View File

@@ -1,14 +1,19 @@
package com.adityachandel.booklore.model.dto.request;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Set;
@Data
public class MetadataRefreshRequest {
@NotNull(message = "Refresh type cannot be null")
private RefreshType refreshType;
private Long libraryId;
private Set<Long> bookIds;
@NotNull(message = "Refresh options cannot be null")
private MetadataRefreshOptions refreshOptions;
public enum RefreshType {

View File

@@ -1,8 +1,6 @@
package com.adityachandel.booklore.quartz;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.model.dto.request.BooksMetadataRefreshRequest;
import com.adityachandel.booklore.model.dto.request.LibraryMetadataRefreshRequest;
import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest;
import lombok.AllArgsConstructor;
import org.quartz.*;
@@ -14,31 +12,23 @@ public class JobSchedulerService {
private final Scheduler scheduler;
public void scheduleMetadataRefresh(LibraryMetadataRefreshRequest request) {
scheduleJob(request, RefreshLibraryMetadataJob.class, "libraryMetadataJob");
}
public void scheduleMetadataRefreshV2(MetadataRefreshRequest request) {
scheduleJob(request, RefreshMetadataJobV2.class, "libraryMetadataJobV2");
scheduleJob(request);
}
public void scheduleBookMetadataRefresh(BooksMetadataRefreshRequest request) {
scheduleJob(request, RefreshBooksMetadataJob.class, "booksMetadataJob");
}
private <T> void scheduleJob(T request, Class<? extends Job> jobClass, String name) {
private <T> void scheduleJob(T request) {
try {
JobDataMap jobDataMap = new JobDataMap();
jobDataMap.put("request", request);
JobDetail jobDetail = JobBuilder.newJob(jobClass)
.withIdentity(name, "Metadata")
JobDetail jobDetail = JobBuilder.newJob(RefreshMetadataJob.class)
.withIdentity("metadataRefreshJobV2", "metadataRefreshJobV2")
.usingJobData(jobDataMap)
.build();
Trigger trigger = TriggerBuilder.newTrigger()
.forJob(jobDetail)
.withIdentity(name, "Metadata")
.withIdentity("metadataRefreshJobV2", "metadataRefreshJobV2")
.startNow()
.build();

View File

@@ -20,12 +20,4 @@ public class QuartzConfig {
jobFactory.setApplicationContext(applicationContext);
return jobFactory;
}
@Bean
public JobDetail refreshMetadataJobDetail() {
return JobBuilder.newJob(RefreshLibraryMetadataJob.class)
.withIdentity("refreshMetadataJob")
.storeDurably()
.build();
}
}

View File

@@ -1,34 +0,0 @@
package com.adityachandel.booklore.quartz;
import com.adityachandel.booklore.model.dto.request.BooksMetadataRefreshRequest;
import com.adityachandel.booklore.model.dto.request.LibraryMetadataRefreshRequest;
import com.adityachandel.booklore.service.metadata.BookMetadataService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.stereotype.Component;
import java.util.stream.Collectors;
@Slf4j
@Component
@AllArgsConstructor
@DisallowConcurrentExecution
public class RefreshBooksMetadataJob implements Job {
private BookMetadataService bookMetadataService;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
BooksMetadataRefreshRequest request = (BooksMetadataRefreshRequest) context.getMergedJobDataMap().get("request");
log.info("Refreshing metadata for Books: {}", request.getBookIds().stream().map(String::valueOf).collect(Collectors.joining(", ")));
bookMetadataService.refreshBooksMetadata(request);
} catch (Exception e) {
throw new JobExecutionException("Error occurred while executing metadata refresh job", e);
}
}
}

View File

@@ -1,31 +0,0 @@
package com.adityachandel.booklore.quartz;
import com.adityachandel.booklore.model.dto.request.LibraryMetadataRefreshRequest;
import com.adityachandel.booklore.service.metadata.BookMetadataService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@AllArgsConstructor
@DisallowConcurrentExecution
public class RefreshLibraryMetadataJob implements Job {
private BookMetadataService bookMetadataService;
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
LibraryMetadataRefreshRequest request = (LibraryMetadataRefreshRequest) context.getMergedJobDataMap().get("request");
log.info("Refreshing metadata for library ID: {}", request.getLibraryId());
bookMetadataService.refreshLibraryMetadata(request);
} catch (Exception e) {
throw new JobExecutionException("Error occurred while executing metadata refresh job", e);
}
}
}

View File

@@ -1,6 +1,5 @@
package com.adityachandel.booklore.quartz;
import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest;
import com.adityachandel.booklore.service.metadata.BookMetadataService;
import lombok.AllArgsConstructor;
@@ -15,7 +14,7 @@ import org.springframework.stereotype.Component;
@Component
@AllArgsConstructor
@DisallowConcurrentExecution
public class RefreshMetadataJobV2 implements Job {
public class RefreshMetadataJob implements Job {
private BookMetadataService bookMetadataService;
@@ -23,7 +22,7 @@ public class RefreshMetadataJobV2 implements Job {
public void execute(JobExecutionContext context) throws JobExecutionException {
try {
MetadataRefreshRequest request = (MetadataRefreshRequest) context.getMergedJobDataMap().get("request");
bookMetadataService.refreshMetadataV2(request);
bookMetadataService.refreshMetadata(request);
} catch (Exception e) {
throw new JobExecutionException("Error occurred while executing metadata refresh job", e);
}

View File

@@ -4,8 +4,6 @@ import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.mapper.BookMapper;
import com.adityachandel.booklore.model.dto.Author;
import com.adityachandel.booklore.model.dto.Book;
import com.adityachandel.booklore.model.dto.request.BooksMetadataRefreshRequest;
import com.adityachandel.booklore.model.dto.request.LibraryMetadataRefreshRequest;
import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
import com.adityachandel.booklore.model.dto.request.MetadataRefreshRequest;
import com.adityachandel.booklore.model.entity.BookEntity;
@@ -18,9 +16,7 @@ import com.adityachandel.booklore.service.NotificationService;
import com.adityachandel.booklore.service.metadata.model.FetchMetadataRequest;
import com.adityachandel.booklore.service.metadata.model.FetchedBookMetadata;
import com.adityachandel.booklore.service.metadata.model.MetadataProvider;
import com.adityachandel.booklore.service.metadata.parser.AmazonBookParser;
import com.adityachandel.booklore.service.metadata.parser.GoodReadsParser;
import com.adityachandel.booklore.service.metadata.parser.GoogleParser;
import com.adityachandel.booklore.service.metadata.parser.BookParser;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -33,34 +29,32 @@ import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
import static com.adityachandel.booklore.model.stomp.LogNotification.createLogNotification;
import static com.adityachandel.booklore.service.metadata.model.MetadataProvider.*;
@Slf4j
@Service
@AllArgsConstructor
public class BookMetadataService {
private final GoogleParser googleParser;
private final AmazonBookParser amazonBookParser;
private final GoodReadsParser goodReadsParser;
private final BookRepository bookRepository;
private final LibraryRepository libraryRepository;
private final BookMapper bookMapper;
private final BookMetadataUpdater bookMetadataUpdater;
private final NotificationService notificationService;
private final Map<MetadataProvider, BookParser> parserMap;
public List<FetchedBookMetadata> fetchMetadataList(long bookId, FetchMetadataRequest request) {
public List<FetchedBookMetadata> fetchMetadataForRequest(long bookId, FetchMetadataRequest request) {
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
Book book = bookMapper.toBook(bookEntity);
List<CompletableFuture<List<FetchedBookMetadata>>> futures = request.getProviders().stream()
.map(provider -> CompletableFuture.supplyAsync(() -> fetchMetadataFromProvider(provider, book, request))
List<List<FetchedBookMetadata>> allMetadata = request.getProviders().stream()
.map(provider -> CompletableFuture.supplyAsync(() -> fetchMetadataListFromAProvider(provider, bookMapper.toBook(bookEntity), request))
.exceptionally(e -> {
log.error("Error fetching metadata from provider: {}", provider, e);
return List.of();
}))
.toList();
List<List<FetchedBookMetadata>> allMetadata = futures.stream().map(CompletableFuture::join).toList();
.toList()
.stream().map(CompletableFuture::join).toList();
List<FetchedBookMetadata> interleavedMetadata = new ArrayList<>();
int maxSize = allMetadata.stream().mapToInt(List::size).max().orElse(0);
@@ -76,73 +70,34 @@ public class BookMetadataService {
return interleavedMetadata;
}
private List<FetchedBookMetadata> fetchMetadataFromProvider(MetadataProvider provider, Book book, FetchMetadataRequest request) {
if (provider == MetadataProvider.Amazon) {
return amazonBookParser.fetchMetadata(book, request);
} else if (provider == MetadataProvider.GoodReads) {
return goodReadsParser.fetchMetadata(book, request);
} else if (provider == MetadataProvider.Google) {
return googleParser.fetchMetadata(book, request);
} else {
throw ApiError.METADATA_SOURCE_NOT_IMPLEMENT_OR_DOES_NOT_EXIST.createException();
}
public List<FetchedBookMetadata> fetchMetadataListFromAProvider(MetadataProvider provider, Book book, FetchMetadataRequest request) {
return getParser(provider).fetchMetadata(book, request);
}
public FetchedBookMetadata fetchTopMetadata(MetadataProvider provider, Book book) {
FetchMetadataRequest fetchMetadataRequest = FetchMetadataRequest.builder()
.isbn(book.getMetadata().getIsbn10())
.author(book.getMetadata().getAuthors().stream().map(Author::getName).collect(Collectors.joining(", ")))
.title(book.getMetadata().getTitle())
.bookId(book.getId())
.build();
if (provider == MetadataProvider.Amazon) {
return amazonBookParser.fetchTopMetadata(book, fetchMetadataRequest);
} else if (provider == MetadataProvider.GoodReads) {
return goodReadsParser.fetchTopMetadata(book, fetchMetadataRequest);
} else if (provider == MetadataProvider.Google) {
return goodReadsParser.fetchTopMetadata(book, fetchMetadataRequest);
} else {
throw ApiError.METADATA_SOURCE_NOT_IMPLEMENT_OR_DOES_NOT_EXIST.createException();
}
public FetchedBookMetadata fetchTopMetadataFromAProvider(MetadataProvider provider, Book book) {
return getParser(provider).fetchTopMetadata(book, buildFetchMetadataRequestFromBook(book));
}
@Transactional
public void refreshLibraryMetadata(LibraryMetadataRefreshRequest request) {
LibraryEntity libraryEntity = libraryRepository.findById(request.getLibraryId()).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(request.getLibraryId()));
List<BookEntity> books = libraryEntity.getBookEntities().stream()
.sorted(Comparator.comparing(BookEntity::getFileName, Comparator.nullsLast(String::compareTo)))
.toList();
refreshBooksMetadata(books, request.getMetadataProvider(), request.isReplaceCover());
log.info("Library Refresh Metadata task completed!");
}
@Transactional
public void refreshBooksMetadata(BooksMetadataRefreshRequest request) {
List<BookEntity> books = bookRepository.findAllByIdIn(request.getBookIds()).stream()
.sorted(Comparator.comparing(BookEntity::getFileName, Comparator.nullsLast(String::compareTo)))
.toList();
refreshBooksMetadata(books, request.getMetadataProvider(), request.isReplaceCover());
log.info("Books Refresh Metadata task completed!");
}
@Transactional
public void refreshBooksMetadata(List<BookEntity> books, MetadataProvider metadataProvider, boolean replaceCover) {
try {
for (BookEntity bookEntity : books) {
FetchedBookMetadata metadata = fetchTopMetadata(metadataProvider, bookMapper.toBook(bookEntity));
if (metadata != null) {
updateBookMetadata(bookEntity, metadata, replaceCover);
if (metadataProvider == MetadataProvider.GoodReads) {
Thread.sleep(Duration.ofSeconds(ThreadLocalRandom.current().nextInt(3, 10)).toMillis());
}
public void refreshMetadata(MetadataRefreshRequest request) {
log.info("Refresh Metadata task started!");
List<MetadataProvider> providers = prepareProviders(request);
List<BookEntity> books = getBookEntities(request);
for (BookEntity bookEntity : books) {
try {
Map<MetadataProvider, FetchedBookMetadata> metadataMap = fetchMetadataForBook(providers, bookEntity);
if (providers.contains(GoodReads)) {
Thread.sleep(Duration.ofSeconds(ThreadLocalRandom.current().nextInt(2, 6)).toMillis());
}
FetchedBookMetadata fetchedBookMetadata = getRawOrCombinedMetadata(request, metadataMap);
updateBookMetadata(bookEntity, fetchedBookMetadata, shouldRefreshCovers(request));
} catch (Exception e) {
log.error("Error while updating book metadata, book: {}", bookEntity.getFileName(), e);
}
} catch (Exception e) {
log.error("Error while parsing library books", e);
}
log.info("Refresh Metadata task completed!");
}
@Transactional
protected void updateBookMetadata(BookEntity bookEntity, FetchedBookMetadata metadata, boolean replaceCover) {
if (metadata != null) {
@@ -156,43 +111,52 @@ public class BookMetadataService {
}
@Transactional
public void refreshMetadataV2(MetadataRefreshRequest request) {
log.info("Refresh Metadata V2 task started!");
MetadataProvider defaultProvider = request.getRefreshOptions().getDefaultProvider();
Set<MetadataProvider> nonDefaultProviders = getNonDefaultProviders(request);
Set<MetadataProvider> allProviders = new HashSet<>(nonDefaultProviders);
allProviders.add(defaultProvider);
List<BookEntity> books = getBookEntities(request);
for (BookEntity bookEntity : books) {
try {
Map<MetadataProvider, FetchedBookMetadata> metadataMap = allProviders.stream()
.map(provider -> CompletableFuture.supplyAsync(() -> fetchTopMetadata(provider, bookMapper.toBook(bookEntity)))
.exceptionally(e -> {
log.error("Error fetching metadata from provider: {}", provider, e);
return null;
}))
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.collect(Collectors.toMap(
FetchedBookMetadata::getProvider,
metadata -> metadata,
(existing, replacement) -> existing
));
FetchedBookMetadata combinedMetadata = setSpecificMetadata(request, metadataMap);
nonDefaultProviders.forEach(provider -> setUnspecificMetadata(metadataMap, combinedMetadata, provider));
setUnspecificMetadata(metadataMap, combinedMetadata, defaultProvider);
if (allProviders.contains(MetadataProvider.GoodReads)) {
Thread.sleep(Duration.ofSeconds(ThreadLocalRandom.current().nextInt(2, 6)).toMillis());
}
updateBookMetadata(bookEntity, combinedMetadata, false);
} catch (Exception e) {
log.error("Error while updating book metadata, book: {}", bookEntity.getFileName(), e);
}
protected List<MetadataProvider> prepareProviders(MetadataRefreshRequest request) {
if (request.getRefreshOptions().getFieldOptions() == null) {
return List.of(request.getRefreshOptions().getDefaultProvider());
} else {
Set<MetadataProvider> allProviders = new HashSet<>(getNonDefaultProviders(request));
allProviders.add(request.getRefreshOptions().getDefaultProvider());
return new ArrayList<>(allProviders);
}
log.info("Refresh Metadata V2 task completed!");
}
@Transactional
protected Map<MetadataProvider, FetchedBookMetadata> fetchMetadataForBook(List<MetadataProvider> providers, BookEntity bookEntity) {
return providers.stream()
.map(provider -> CompletableFuture.supplyAsync(() -> fetchTopMetadataFromAProvider(provider, bookMapper.toBook(bookEntity)))
.exceptionally(e -> {
log.error("Error fetching metadata from provider: {}", provider, e);
return null;
}))
.map(CompletableFuture::join)
.filter(Objects::nonNull)
.collect(Collectors.toMap(
FetchedBookMetadata::getProvider,
metadata -> metadata,
(existing, replacement) -> existing
));
}
@Transactional
protected FetchedBookMetadata getRawOrCombinedMetadata(MetadataRefreshRequest request, Map<MetadataProvider, FetchedBookMetadata> metadataMap) {
if (request.getRefreshOptions().getFieldOptions() == null) {
return metadataMap.get(request.getRefreshOptions().getDefaultProvider());
} else {
MetadataProvider defaultProvider = request.getRefreshOptions().getDefaultProvider();
Set<MetadataProvider> nonDefaultProviders = getNonDefaultProviders(request);
FetchedBookMetadata combinedMetadata = setSpecificMetadata(request, metadataMap);
nonDefaultProviders.forEach(provider -> setUnspecificMetadata(metadataMap, combinedMetadata, provider));
setUnspecificMetadata(metadataMap, combinedMetadata, defaultProvider);
return combinedMetadata;
}
}
@Transactional
protected boolean shouldRefreshCovers(MetadataRefreshRequest request) {
return request.getRefreshOptions().isRefreshCovers();
}
@Transactional
@@ -255,46 +219,20 @@ public class BookMetadataService {
return values;
}
/*@Transactional
protected String resolveFieldAsString(Map<MetadataProvider, FetchedBookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider) {
if (fieldProvider.getDefaultProvider() != null && metadataMap.containsKey(fieldProvider.getDefaultProvider())) {
return metadataMap.get(fieldProvider.getDefaultProvider()).getTitle();
}
if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
return metadataMap.get(fieldProvider.getP2()).getTitle();
}
if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
return metadataMap.get(fieldProvider.getP1()).getTitle();
}
return null;
}
@Transactional
protected List<String> resolveFieldAsList(Map<MetadataProvider, FetchedBookMetadata> metadataMap, MetadataRefreshOptions.FieldProvider fieldProvider) {
if (fieldProvider.getDefaultProvider() != null && metadataMap.containsKey(fieldProvider.getDefaultProvider())) {
return metadataMap.get(fieldProvider.getDefaultProvider()).getAuthors();
}
if (fieldProvider.getP2() != null && metadataMap.containsKey(fieldProvider.getP2())) {
return metadataMap.get(fieldProvider.getP2()).getAuthors();
}
if (fieldProvider.getP1() != null && metadataMap.containsKey(fieldProvider.getP1())) {
return metadataMap.get(fieldProvider.getP1()).getAuthors();
}
return Collections.emptyList();
}*/
@Transactional
protected List<BookEntity> getBookEntities(MetadataRefreshRequest request) {
MetadataRefreshRequest.RefreshType refreshType = request.getRefreshType();
List<BookEntity> books = new ArrayList<>();
if (refreshType == MetadataRefreshRequest.RefreshType.LIBRARY) {
LibraryEntity libraryEntity = libraryRepository.findById(request.getLibraryId()).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(request.getLibraryId()));
books = libraryEntity.getBookEntities().stream()
.sorted(Comparator.comparing(BookEntity::getFileName, Comparator.nullsLast(String::compareTo)))
.toList();
} else if (refreshType == MetadataRefreshRequest.RefreshType.BOOKS) {
//TODO
if (refreshType != MetadataRefreshRequest.RefreshType.LIBRARY && refreshType != MetadataRefreshRequest.RefreshType.BOOKS) {
throw ApiError.INVALID_REFRESH_TYPE.createException();
}
List<BookEntity> books = switch (refreshType) {
case LIBRARY -> {
LibraryEntity libraryEntity = libraryRepository.findById(request.getLibraryId()).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(request.getLibraryId()));
yield libraryEntity.getBookEntities();
}
case BOOKS -> bookRepository.findAllByIdIn(request.getBookIds());
};
books.sort(Comparator.comparing(BookEntity::getFileName, Comparator.nullsLast(String::compareTo)));
return books;
}
@@ -328,4 +266,23 @@ public class BookMetadataService {
}
}
}
private FetchMetadataRequest buildFetchMetadataRequestFromBook(Book book) {
return FetchMetadataRequest.builder()
.isbn(book.getMetadata().getIsbn10())
.author(book.getMetadata().getAuthors().stream()
.map(Author::getName)
.collect(Collectors.joining(", ")))
.title(book.getMetadata().getTitle())
.bookId(book.getId())
.build();
}
private BookParser getParser(MetadataProvider provider) {
BookParser parser = parserMap.get(provider);
if (parser == null) {
throw ApiError.METADATA_SOURCE_NOT_IMPLEMENT_OR_DOES_NOT_EXIST.createException();
}
return parser;
}
}

View File

@@ -24,7 +24,7 @@
"ngx-extended-pdf-viewer": "^22.0.0",
"ngx-infinite-scroll": "^19.0.0",
"primeicons": "^7.0.0",
"primeng": "19.0.3",
"primeng": "19.0.5",
"quill": "^2.0.3",
"rxjs": "~7.8.0",
"tailwindcss-primeui": "^0.3.4",
@@ -12037,9 +12037,9 @@
"license": "MIT"
},
"node_modules/primeng": {
"version": "19.0.3",
"resolved": "https://registry.npmjs.org/primeng/-/primeng-19.0.3.tgz",
"integrity": "sha512-cv15SG37WcMFlGZY9cyMULM8l91PTYRONdvMoDECNnH7LV0EkG1jvrsAezn8c4IsoJheofnoZez9D6Z9TpS4PQ==",
"version": "19.0.5",
"resolved": "https://registry.npmjs.org/primeng/-/primeng-19.0.5.tgz",
"integrity": "sha512-3IMWTUykIyZpT7d+pD7KzB+68GcY8/xOV10V1Tf09cPkPuwXlFP+NAVGIqAOXBzkw+RmJksSBoa13bpS/fMo1g==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@primeuix/styled": "^0.3.2",

View File

@@ -26,7 +26,7 @@
"ngx-extended-pdf-viewer": "^22.0.0",
"ngx-infinite-scroll": "^19.0.0",
"primeicons": "^7.0.0",
"primeng": "19.0.3",
"primeng": "19.0.5",
"quill": "^2.0.3",
"rxjs": "~7.8.0",
"tailwindcss-primeui": "^0.3.4",

View File

@@ -6,7 +6,7 @@ import {Divider} from 'primeng/divider';
import {NgForOf, NgIf} from '@angular/common';
import {MetadataProvider} from '../../book/model/provider.model';
import {BookService} from '../../book/service/book.service';
import {FetchMetadataRequest} from '../../book/model/request/fetch-metadata-request.model';
import {FetchMetadataRequest} from '../../book/model/request/metadata/fetch-metadata-request.model';
import {FetchedMetadata} from '../../book/model/book.model';
import {ProgressSpinner} from 'primeng/progressspinner';
import {MetadataPickerComponent} from '../metadata-picker/metadata-picker.component';

View File

@@ -18,8 +18,8 @@ import {LibraryShelfMenuService} from '../../service/library-shelf-menu.service'
import {SelectButtonChangeEvent} from 'primeng/selectbutton';
import {BookTableComponent} from '../book-table/book-table.component';
import {animate, state, style, transition, trigger} from '@angular/animations';
import {MetadataProvider} from '../../model/provider.model';
import {MetadataService} from '../../service/metadata.service';
import {MetadataFetchOptionsComponent} from '../../../metadata-fetch-options/metadata-fetch-options.component';
import {MetadataRefreshType} from '../../model/request/metadata/metadata-refresh-type.enum';
export enum EntityType {
LIBRARY = 'Library',
@@ -79,7 +79,6 @@ export class BookBrowserComponent implements OnInit {
private shelfService: ShelfService,
private dialogService: DialogService,
private sortService: SortService,
private metadataService: MetadataService,
private libraryShelfMenuService: LibraryShelfMenuService) {
}
@@ -300,10 +299,6 @@ export class BookBrowserComponent implements OnInit {
this.bookTitle$.next(newTitle);
}
openMetadataOptionsDialog() {
}
openShelfAssigner() {
this.dynamicDialogRef = this.dialogService.open(ShelfAssignerComponent, {
header: `Update Books Shelves`,
@@ -328,32 +323,15 @@ export class BookBrowserComponent implements OnInit {
}
updateMetadata() {
this.metadataService.autoRefreshBooksMetadata(this.selectedBooks, MetadataProvider.Amazon, false).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Metadata Update Scheduled',
detail: 'The metadata update for the selected books has been successfully scheduled.'
});
},
error: (e) => {
if (e.status === 409) {
this.messageService.add({
severity: 'error',
summary: 'Task Already Running',
life: 5000,
detail: 'A metadata refresh task is already in progress. Please wait for it to complete before starting another one.'
});
} else {
this.messageService.add({
severity: 'error',
summary: 'Metadata Update Failed',
life: 5000,
detail: 'An unexpected error occurred while scheduling the metadata update. Please try again later or contact support if the issue persists.'
});
}
this.dialogService.open(MetadataFetchOptionsComponent, {
header: 'Metadata Refresh Options',
modal: true,
closable: true,
data: {
bookIds: this.selectedBooks,
metadataRefreshType: MetadataRefreshType.BOOKS
}
});
})
}
}

View File

@@ -1,11 +1,9 @@
<div class="layout-topbar">
<a class="layout-topbar-logo" routerLink="">
<img src="assets/layout/images/{{'logo-dark'}}.svg"
alt="logo">
<span class="flex">
<p>Book</p>
<p class="half-title">lore</p>
<p class="half-title text-3xl">lore</p>
</span>
</a>

View File

@@ -1,4 +1,4 @@
import {MetadataProvider} from '../provider.model';
import {MetadataProvider} from '../../provider.model';
export interface BookAutoMetadataRefresh {
bookIds: number[],

View File

@@ -1,4 +1,4 @@
import {MetadataProvider} from '../provider.model';
import {MetadataProvider} from '../../provider.model';
export interface LibraryAutoMetadataRefreshRequest {
libraryId: number,

View File

@@ -0,0 +1,19 @@
export interface MetadataRefreshOptions {
defaultProvider: string;
refreshCovers: boolean;
fieldOptions?: FieldOptions;
}
export interface FieldProvider {
default: string | null;
p2: string | null;
p1: string | null;
}
export interface FieldOptions {
title: FieldProvider;
description: FieldProvider;
authors: FieldProvider;
categories: FieldProvider;
cover: FieldProvider;
}

View File

@@ -0,0 +1,9 @@
import {MetadataRefreshType} from './metadata-refresh-type.enum';
import {MetadataRefreshOptions} from './metadata-refresh-options.model';
export interface MetadataRefreshRequest {
refreshType: MetadataRefreshType;
libraryId?: number;
bookIds?: Set<number>;
refreshOptions: MetadataRefreshOptions;
}

View File

@@ -0,0 +1,4 @@
export enum MetadataRefreshType {
BOOKS = 'BOOKS',
LIBRARY = 'LIBRARY'
}

View File

@@ -9,6 +9,7 @@ import {MetadataService} from './metadata.service';
import {MetadataProvider} from '../model/provider.model';
import {DialogService} from 'primeng/dynamicdialog';
import {MetadataFetchOptionsComponent} from '../../metadata-fetch-options/metadata-fetch-options.component';
import {MetadataRefreshType} from '../model/request/metadata/metadata-refresh-type.enum';
@Injectable({
providedIn: 'root',
@@ -94,7 +95,8 @@ export class LibraryShelfMenuService {
modal: true,
closable: true,
data: {
libraryId: entity?.id
libraryId: entity?.id,
metadataRefreshType: MetadataRefreshType.LIBRARY
}
})
/*this.confirmationService.confirm({

View File

@@ -1,16 +1,12 @@
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {MetadataProvider} from "../model/provider.model";
import {Observable} from "rxjs";
import {BookAutoMetadataRefresh} from "../model/request/book-auto-metadata-refresh.model";
import {LibraryAutoMetadataRefreshRequest} from "../model/request/library-auto-metadata-refresh.model";
import {FetchMetadataRequest} from "../model/request/fetch-metadata-request.model";
import {FetchMetadataRequest} from "../model/request/metadata/fetch-metadata-request.model";
import {BookMetadata, FetchedMetadata} from "../model/book.model";
import {BookMetadataBI} from "../model/book-metadata-for-book-info.model";
import {tap} from "rxjs/operators";
import {BookService} from "./book.service";
import {MetadataRefreshOptions} from '../../metadata-advanced-fetch-options/metadata-advanced-fetch-options.component';
import {MetadataRefreshRequest} from '../../metadata-fetch-options/metadata-fetch-options.component';
import {MetadataRefreshRequest} from '../model/request/metadata/metadata-refresh-request.model';
@Injectable({
providedIn: 'root'
@@ -34,25 +30,7 @@ export class MetadataService {
);
}
autoRefreshLibraryBooksMetadataV2(metadataRefreshRequest: MetadataRefreshRequest) {
autoRefreshMetadata(metadataRefreshRequest: MetadataRefreshRequest) {
return this.http.put<void>(`${this.url}/refreshV2`, metadataRefreshRequest);
}
autoRefreshLibraryBooksMetadata(libraryId: number, metadataProvider: MetadataProvider, replaceCover: boolean): Observable<void> {
const requestPayload: LibraryAutoMetadataRefreshRequest = {
libraryId: libraryId,
metadataProvider: metadataProvider,
replaceCover: replaceCover
}
return this.http.put<void>(`${this.url}/library/${libraryId}/refresh`, requestPayload);
}
autoRefreshBooksMetadata(selectedBooks: Set<number>, metadataProvider: MetadataProvider, replaceCover: boolean): Observable<void> {
const requestPayload: BookAutoMetadataRefresh = {
bookIds: Array.from(selectedBooks),
metadataProvider: metadataProvider,
replaceCover
};
return this.http.put<void>(`${this.url}/books/refresh`, requestPayload);
}
}

View File

@@ -5,28 +5,7 @@ import {NgForOf, TitleCasePipe} from '@angular/common';
import {Checkbox} from 'primeng/checkbox';
import {Button} from 'primeng/button';
import {MessageService} from 'primeng/api';
export interface MetadataRefreshOptions {
refreshType: string;
defaultProvider: string;
refreshCovers: boolean;
fieldOptions: FieldOptions;
}
export interface FieldProvider {
default: string | null;
p2: string | null;
p1: string | null;
}
export interface FieldOptions {
title: FieldProvider;
description: FieldProvider;
authors: FieldProvider;
categories: FieldProvider;
cover: FieldProvider;
}
import {FieldOptions, FieldProvider, MetadataRefreshOptions} from '../book/model/request/metadata/metadata-refresh-options.model';
@Component({
selector: 'app-metadata-advanced-fetch-options',
@@ -69,7 +48,6 @@ export class MetadataAdvancedFetchOptionsComponent {
});
if (allProvidersSelected) {
const metadataRefreshOptions: MetadataRefreshOptions = {
refreshType: 'LIBRARY',
defaultProvider: this.allDefault.value,
refreshCovers: this.refreshCovers,
fieldOptions: this.fieldOptions

View File

@@ -1,18 +1,11 @@
<div class="flex flex-col gap-6 min-w-full custom-table p-6">
<!-- Provider Select -->
<p-select
[options]="providers"
[(ngModel)]="selectedProvider"
placeholder="Select Provider"
class="w-full"
></p-select>
<div class="flex flex-col gap-6 p-6 min-w-96">
<p-select fluid [options]="providers" [(ngModel)]="selectedProvider" placeholder="Select Provider" class="flex-grow"></p-select>
<!-- Refresh Covers Section -->
<div class="flex flex-row items-center gap-4">
<p class="pl-2">Refresh covers: </p>
<p-checkbox [(ngModel)]="refreshCovers" [binary]="true"></p-checkbox>
</div>
<!-- Submit Button -->
<p-button class="pt-4 justify-end" label="Submit" icon="pi pi-check"></p-button>
<p-button class="flex pt-4 justify-end" label="Submit" [disabled]="!selectedProvider" (onClick)="submit()"></p-button>
</div>

View File

@@ -1,23 +1,29 @@
import { Component } from '@angular/core';
import {Component, EventEmitter, Output} from '@angular/core';
import {Button} from 'primeng/button';
import {Checkbox} from 'primeng/checkbox';
import {Select} from 'primeng/select';
import {FormsModule} from '@angular/forms';
import {MetadataRefreshOptions} from '../book/model/request/metadata/metadata-refresh-options.model';
@Component({
selector: 'app-metadata-basic-fetch-options',
standalone: true,
templateUrl: './metadata-basic-fetch-options.component.html',
imports: [
Button,
Checkbox,
Select,
FormsModule
],
styleUrl: './metadata-basic-fetch-options.component.scss'
styleUrl: './metadata-basic-fetch-options.component.scss',
imports: [Button, Checkbox, Select, FormsModule]
})
export class MetadataBasicFetchOptionsComponent {
providers: string[] = ['Amazon', 'Google', 'GoodReads'];
selectedProvider!: string;
refreshCovers: boolean = false;
@Output() metadataOptionsSubmitted: EventEmitter<MetadataRefreshOptions> = new EventEmitter<MetadataRefreshOptions>();
submit() {
const metadataRefreshOptions: MetadataRefreshOptions = {
defaultProvider: this.selectedProvider,
refreshCovers: this.refreshCovers,
};
this.metadataOptionsSubmitted.emit(metadataRefreshOptions);
}
}

View File

@@ -1,10 +1,10 @@
<div class="flex flex-col items-center space-y-6">
<div class="flex flex-col space-y-6">
<ng-container *ngIf="isBasicMode; else advancedOptions">
<app-metadata-basic-fetch-options ></app-metadata-basic-fetch-options>
<app-metadata-basic-fetch-options (metadataOptionsSubmitted)="onMetadataSubmit($event)"></app-metadata-basic-fetch-options>
</ng-container>
<ng-template #advancedOptions>
<app-metadata-advanced-fetch-options (metadataOptionsSubmitted)="onAdvancedMetadataOptionSubmit($event)"></app-metadata-advanced-fetch-options>
<app-metadata-advanced-fetch-options (metadataOptionsSubmitted)="onMetadataSubmit($event)"></app-metadata-advanced-fetch-options>
</ng-template>
<p-divider></p-divider>

View File

@@ -1,23 +1,15 @@
import {Component} from '@angular/core';
import {DynamicDialogConfig} from 'primeng/dynamicdialog';
import {MetadataAdvancedFetchOptionsComponent, MetadataRefreshOptions} from '../metadata-advanced-fetch-options/metadata-advanced-fetch-options.component';
import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {MetadataAdvancedFetchOptionsComponent} from '../metadata-advanced-fetch-options/metadata-advanced-fetch-options.component';
import {Divider} from 'primeng/divider';
import {Button} from 'primeng/button';
import {MetadataBasicFetchOptionsComponent} from '../metadata-basic-fetch-options/metadata-basic-fetch-options.component';
import {NgIf} from '@angular/common';
import {MetadataService} from '../book/service/metadata.service';
export interface MetadataRefreshRequest {
refreshType: RefreshType;
libraryId?: number;
bookIds?: Set<number>;
refreshOptions: MetadataRefreshOptions;
}
enum RefreshType {
BOOKS = 'BOOKS',
LIBRARY = 'LIBRARY'
}
import {MetadataRefreshRequest} from '../book/model/request/metadata/metadata-refresh-request.model';
import {MetadataRefreshType} from '../book/model/request/metadata/metadata-refresh-type.enum';
import {MessageService} from 'primeng/api';
import {MetadataRefreshOptions} from '../book/model/request/metadata/metadata-refresh-options.model';
@Component({
selector: 'app-metadata-fetch-options',
@@ -33,30 +25,57 @@ enum RefreshType {
styleUrl: './metadata-fetch-options.component.scss'
})
export class MetadataFetchOptionsComponent {
isBasicMode: boolean = false;
isBasicMode: boolean = true;
libraryId!: number;
bookIds!: Set<number>;
metadataRefreshType!: MetadataRefreshType;
constructor(private dynamicDialogConfig: DynamicDialogConfig, private metadataService: MetadataService) {
constructor(private dynamicDialogConfig: DynamicDialogConfig,
private dynamicDialogRef: DynamicDialogRef,
private metadataService: MetadataService,
private messageService: MessageService) {
this.libraryId = dynamicDialogConfig.data.libraryId;
this.bookIds = dynamicDialogConfig.data.bookIds;
this.metadataRefreshType = dynamicDialogConfig.data.metadataRefreshType;
}
toggleMode() {
this.isBasicMode = !this.isBasicMode;
}
onAdvancedMetadataOptionSubmit(metadataRefreshOptions: MetadataRefreshOptions) {
onMetadataSubmit(metadataRefreshOptions: MetadataRefreshOptions) {
const metadataRefreshRequest: MetadataRefreshRequest = {
refreshType: RefreshType.LIBRARY,
libraryId: this.libraryId,
refreshOptions: metadataRefreshOptions
refreshType: this.metadataRefreshType,
refreshOptions: metadataRefreshOptions,
...(this.metadataRefreshType === 'BOOKS' && this.bookIds != null && {bookIds: this.bookIds}),
...(this.metadataRefreshType === 'LIBRARY' && this.libraryId != null && {libraryId: this.libraryId})
};
this.metadataService.autoRefreshLibraryBooksMetadataV2(metadataRefreshRequest).subscribe({
this.metadataService.autoRefreshMetadata(metadataRefreshRequest).subscribe({
next: () => {
// Handle success
this.messageService.add({
severity: 'success',
summary: 'Metadata Update Scheduled',
detail: 'The metadata update for the selected books has been successfully scheduled.'
});
},
error: (error) => {
// Handle error
error: (e) => {
if (e.status === 409) {
this.messageService.add({
severity: 'error',
summary: 'Task Already Running',
life: 5000,
detail: 'A metadata refresh task is already in progress. Please wait for it to complete before starting another one.'
});
} else {
this.messageService.add({
severity: 'error',
summary: 'Metadata Update Failed',
life: 5000,
detail: 'An unexpected error occurred while scheduling the metadata update. Please try again later or contact support if the issue persists.'
});
}
}
});
this.dynamicDialogRef.close();
}
}

View File

@@ -23,7 +23,7 @@
display: flex;
align-items: center;
color: var(--surface-900);
font-size: 1.5rem;
font-size: 1.7rem;
font-weight: 500;
width: 200px;
border-radius: 12px;