mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat: comic metadata picker, lazy-load providers, and UI improvements (#2679)
* feat: add comic metadata support to metadata picker and fix Comicvine parser * feat: lazy-load Comicvine issue details on selection * feat: lazy-load detail metadata for Amazon, GoodReads, and Audible parsers * fix: prevent spurious comic_metadata row creation for non-comic books * fix: extract rich previews from Audible search and reorder picker sections * feat: redesign metadata editor layout with collapsible sections and boolean selects * fix: implement per-field comic metadata locks replacing grouped locks * feat: add comic metadata filters and fix visibleFilters backend support * fix: use human-readable role labels in comic creator filter * fix: auto-populate comic metadata from ComicVine during metadata fetch * refactor: clean up ComicvineBookParser remove duplication and comments * fix: use ComicMetadata webLink for ComicVine favicon URL * fix: cache library options to prevent Set All dropdowns from resetting * fix: stream book content from disk instead of loading entire file into memory * fix: increase max visible filters from 15 to 20 * feat: replace filter multiselect with drag-and-drop reorderable list * fix: use audiobook-specific cover paths and cache busting for audiobook thumbnail updates * chore: enforce mandatory screenshots and stricter testing requirements in PR template * fix: update BookServiceTest to match Resource return type after streaming change --------- Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
@@ -32,13 +32,11 @@ import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -125,9 +123,9 @@ public class BookController {
|
||||
@ApiResponse(responseCode = "200", description = "Book content returned successfully")
|
||||
@GetMapping("/{bookId}/content")
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
public ResponseEntity<ByteArrayResource> getBookContent(
|
||||
public ResponseEntity<Resource> getBookContent(
|
||||
@Parameter(description = "ID of the book") @PathVariable long bookId,
|
||||
@Parameter(description = "Optional book type for alternative format (e.g., EPUB, PDF, MOBI)") @RequestParam(required = false) String bookType) throws IOException {
|
||||
@Parameter(description = "Optional book type for alternative format (e.g., EPUB, PDF, MOBI)") @RequestParam(required = false) String bookType) {
|
||||
return bookService.getBookContent(bookId, bookType);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.booklore.model.MetadataUpdateWrapper;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.request.*;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.enums.MetadataProvider;
|
||||
import org.booklore.model.enums.MetadataReplaceMode;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.metadata.BookMetadataService;
|
||||
@@ -136,5 +137,19 @@ public class MetadataController {
|
||||
metadataManagementService.deleteMetadata(request.getMetadataType(), request.getValuesToDelete());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@Operation(summary = "Get detailed metadata from provider", description = "Fetch full metadata details for a specific item from a provider. Requires metadata edit permission or admin.")
|
||||
@ApiResponse(responseCode = "200", description = "Detailed metadata returned successfully")
|
||||
@GetMapping("/metadata/detail/{provider}/{providerItemId}")
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
public ResponseEntity<BookMetadata> getDetailedProviderMetadata(
|
||||
@Parameter(description = "Metadata provider") @PathVariable MetadataProvider provider,
|
||||
@Parameter(description = "Provider-specific item ID") @PathVariable String providerItemId) {
|
||||
BookMetadata metadata = bookMetadataService.getDetailedProviderMetadata(provider, providerItemId);
|
||||
if (metadata == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,14 +75,12 @@ public class GlobalExceptionHandler {
|
||||
}
|
||||
|
||||
@ExceptionHandler(AsyncRequestNotUsableException.class)
|
||||
public ResponseEntity<ErrorResponse> handleAsyncRequestNotUsableException(AsyncRequestNotUsableException ex) {
|
||||
public void handleAsyncRequestNotUsableException(AsyncRequestNotUsableException ex) {
|
||||
if (ex.getCause() instanceof ClientAbortException) {
|
||||
log.info("Request was canceled by client: {}", ex.getMessage());
|
||||
} else {
|
||||
log.error("Unexpected error occurred during async request handling: ", ex);
|
||||
}
|
||||
ErrorResponse errorResponse = new ErrorResponse(HttpStatus.OK.value(), "Request was canceled by the client.");
|
||||
return new ResponseEntity<>(errorResponse, HttpStatus.OK);
|
||||
}
|
||||
|
||||
@ExceptionHandler(InterruptedException.class)
|
||||
|
||||
@@ -59,6 +59,8 @@ public class BookLoreUserTransformer {
|
||||
case TABLE_COLUMN_PREFERENCE -> userSettings.setTableColumnPreference(objectMapper.readValue(value, new TypeReference<>() {
|
||||
}));
|
||||
case DASHBOARD_CONFIG -> userSettings.setDashboardConfig(objectMapper.readValue(value, BookLoreUser.UserSettings.DashboardConfig.class));
|
||||
case VISIBLE_FILTERS -> userSettings.setVisibleFilters(objectMapper.readValue(value, new TypeReference<>() {
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
switch (settingKey) {
|
||||
|
||||
@@ -77,6 +77,7 @@ public class BookLoreUser {
|
||||
public boolean koReaderEnabled;
|
||||
public boolean enableSeriesView;
|
||||
public boolean autoSaveMetadata;
|
||||
public List<String> visibleFilters;
|
||||
public DashboardConfig dashboardConfig;
|
||||
|
||||
@Data
|
||||
|
||||
@@ -47,7 +47,23 @@ public class ComicMetadata {
|
||||
private Boolean volumeNameLocked;
|
||||
private Boolean volumeNumberLocked;
|
||||
private Boolean storyArcLocked;
|
||||
private Boolean storyArcNumberLocked;
|
||||
private Boolean alternateSeriesLocked;
|
||||
private Boolean alternateIssueLocked;
|
||||
private Boolean imprintLocked;
|
||||
private Boolean formatLocked;
|
||||
private Boolean blackAndWhiteLocked;
|
||||
private Boolean mangaLocked;
|
||||
private Boolean readingDirectionLocked;
|
||||
private Boolean webLinkLocked;
|
||||
private Boolean notesLocked;
|
||||
private Boolean creatorsLocked;
|
||||
private Boolean pencillersLocked;
|
||||
private Boolean inkersLocked;
|
||||
private Boolean coloristsLocked;
|
||||
private Boolean letterersLocked;
|
||||
private Boolean coverArtistsLocked;
|
||||
private Boolean editorsLocked;
|
||||
private Boolean charactersLocked;
|
||||
private Boolean teamsLocked;
|
||||
private Boolean locationsLocked;
|
||||
|
||||
@@ -22,7 +22,8 @@ public enum UserSettingKey {
|
||||
ENABLE_SERIES_VIEW("enableSeriesView", false),
|
||||
HARDCOVER_API_KEY("hardcoverApiKey", false),
|
||||
HARDCOVER_SYNC_ENABLED("hardcoverSyncEnabled", false),
|
||||
AUTO_SAVE_METADATA("autoSaveMetadata", false);
|
||||
AUTO_SAVE_METADATA("autoSaveMetadata", false),
|
||||
VISIBLE_FILTERS("visibleFilters", true);
|
||||
|
||||
|
||||
private final String dbKey;
|
||||
|
||||
@@ -418,6 +418,9 @@ public class BookMetadataEntity {
|
||||
this.abridgedLocked = lock;
|
||||
this.ageRatingLocked = lock;
|
||||
this.contentRatingLocked = lock;
|
||||
if (this.comicMetadata != null) {
|
||||
this.comicMetadata.applyLockToAllFields(lock);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean areAllFieldsLocked() {
|
||||
@@ -463,6 +466,7 @@ public class BookMetadataEntity {
|
||||
&& Boolean.TRUE.equals(this.abridgedLocked)
|
||||
&& Boolean.TRUE.equals(this.ageRatingLocked)
|
||||
&& Boolean.TRUE.equals(this.contentRatingLocked)
|
||||
&& (this.comicMetadata == null || this.comicMetadata.areAllFieldsLocked())
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +122,74 @@ public class ComicMetadataEntity {
|
||||
@Builder.Default
|
||||
private Boolean storyArcLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "story_arc_number_locked")
|
||||
@Builder.Default
|
||||
private Boolean storyArcNumberLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "alternate_series_locked")
|
||||
@Builder.Default
|
||||
private Boolean alternateSeriesLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "alternate_issue_locked")
|
||||
@Builder.Default
|
||||
private Boolean alternateIssueLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "imprint_locked")
|
||||
@Builder.Default
|
||||
private Boolean imprintLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "format_locked")
|
||||
@Builder.Default
|
||||
private Boolean formatLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "black_and_white_locked")
|
||||
@Builder.Default
|
||||
private Boolean blackAndWhiteLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "manga_locked")
|
||||
@Builder.Default
|
||||
private Boolean mangaLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "reading_direction_locked")
|
||||
@Builder.Default
|
||||
private Boolean readingDirectionLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "web_link_locked")
|
||||
@Builder.Default
|
||||
private Boolean webLinkLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "notes_locked")
|
||||
@Builder.Default
|
||||
private Boolean notesLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "creators_locked")
|
||||
@Builder.Default
|
||||
private Boolean creatorsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "pencillers_locked")
|
||||
@Builder.Default
|
||||
private Boolean pencillersLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "inkers_locked")
|
||||
@Builder.Default
|
||||
private Boolean inkersLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "colorists_locked")
|
||||
@Builder.Default
|
||||
private Boolean coloristsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "letterers_locked")
|
||||
@Builder.Default
|
||||
private Boolean letterersLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "cover_artists_locked")
|
||||
@Builder.Default
|
||||
private Boolean coverArtistsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "editors_locked")
|
||||
@Builder.Default
|
||||
private Boolean editorsLocked = Boolean.FALSE;
|
||||
|
||||
@Column(name = "characters_locked")
|
||||
@Builder.Default
|
||||
private Boolean charactersLocked = Boolean.FALSE;
|
||||
@@ -143,7 +207,23 @@ public class ComicMetadataEntity {
|
||||
this.volumeNameLocked = lock;
|
||||
this.volumeNumberLocked = lock;
|
||||
this.storyArcLocked = lock;
|
||||
this.storyArcNumberLocked = lock;
|
||||
this.alternateSeriesLocked = lock;
|
||||
this.alternateIssueLocked = lock;
|
||||
this.imprintLocked = lock;
|
||||
this.formatLocked = lock;
|
||||
this.blackAndWhiteLocked = lock;
|
||||
this.mangaLocked = lock;
|
||||
this.readingDirectionLocked = lock;
|
||||
this.webLinkLocked = lock;
|
||||
this.notesLocked = lock;
|
||||
this.creatorsLocked = lock;
|
||||
this.pencillersLocked = lock;
|
||||
this.inkersLocked = lock;
|
||||
this.coloristsLocked = lock;
|
||||
this.letterersLocked = lock;
|
||||
this.coverArtistsLocked = lock;
|
||||
this.editorsLocked = lock;
|
||||
this.charactersLocked = lock;
|
||||
this.teamsLocked = lock;
|
||||
this.locationsLocked = lock;
|
||||
@@ -154,7 +234,23 @@ public class ComicMetadataEntity {
|
||||
&& Boolean.TRUE.equals(this.volumeNameLocked)
|
||||
&& Boolean.TRUE.equals(this.volumeNumberLocked)
|
||||
&& Boolean.TRUE.equals(this.storyArcLocked)
|
||||
&& Boolean.TRUE.equals(this.storyArcNumberLocked)
|
||||
&& Boolean.TRUE.equals(this.alternateSeriesLocked)
|
||||
&& Boolean.TRUE.equals(this.alternateIssueLocked)
|
||||
&& Boolean.TRUE.equals(this.imprintLocked)
|
||||
&& Boolean.TRUE.equals(this.formatLocked)
|
||||
&& Boolean.TRUE.equals(this.blackAndWhiteLocked)
|
||||
&& Boolean.TRUE.equals(this.mangaLocked)
|
||||
&& Boolean.TRUE.equals(this.readingDirectionLocked)
|
||||
&& Boolean.TRUE.equals(this.webLinkLocked)
|
||||
&& Boolean.TRUE.equals(this.notesLocked)
|
||||
&& Boolean.TRUE.equals(this.creatorsLocked)
|
||||
&& Boolean.TRUE.equals(this.pencillersLocked)
|
||||
&& Boolean.TRUE.equals(this.inkersLocked)
|
||||
&& Boolean.TRUE.equals(this.coloristsLocked)
|
||||
&& Boolean.TRUE.equals(this.letterersLocked)
|
||||
&& Boolean.TRUE.equals(this.coverArtistsLocked)
|
||||
&& Boolean.TRUE.equals(this.editorsLocked)
|
||||
&& Boolean.TRUE.equals(this.charactersLocked)
|
||||
&& Boolean.TRUE.equals(this.teamsLocked)
|
||||
&& Boolean.TRUE.equals(this.locationsLocked);
|
||||
|
||||
@@ -20,7 +20,7 @@ import java.util.Set;
|
||||
public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpecificationExecutor<BookEntity> {
|
||||
Optional<BookEntity> findBookByIdAndLibraryId(long id, long libraryId);
|
||||
|
||||
@EntityGraph(attributePaths = { "metadata", "shelves", "libraryPath", "library", "bookFiles" })
|
||||
@EntityGraph(attributePaths = { "metadata", "metadata.comicMetadata", "shelves", "libraryPath", "library", "bookFiles" })
|
||||
@Query("SELECT b FROM BookEntity b LEFT JOIN FETCH b.bookFiles bf WHERE b.id = :id AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
Optional<BookEntity> findByIdWithBookFiles(@Param("id") Long id);
|
||||
|
||||
@@ -46,19 +46,19 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("SELECT b.id FROM BookEntity b WHERE b.libraryPath.id IN :libraryPathIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<Long> findAllBookIdsByLibraryPathIdIn(@Param("libraryPathIds") Collection<Long> libraryPathIds);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadata();
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByIds(@Param("bookIds") Set<Long> bookIds);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.id IN :bookIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findWithMetadataByIdsWithPagination(@Param("bookIds") Set<Long> bookIds, Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByLibraryId(@Param("libraryId") Long libraryId);
|
||||
|
||||
@@ -66,15 +66,15 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllByLibraryIdWithFiles(@Param("libraryId") Long libraryId);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByShelfId(@Param("shelfId") Long shelfId);
|
||||
|
||||
@EntityGraph(attributePaths = { "metadata", "shelves", "libraryPath", "bookFiles" })
|
||||
@EntityGraph(attributePaths = { "metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles" })
|
||||
@Query("SELECT DISTINCT b FROM BookEntity b JOIN b.bookFiles bf WHERE bf.isBookFormat = true AND bf.fileSizeKb IS NULL AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByFileSizeKbIsNull();
|
||||
|
||||
@@ -279,7 +279,7 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
* Find books by series name for a library when groupUnknown=true.
|
||||
* Uses the first bookFile.fileName as fallback when metadata.seriesName is null.
|
||||
*/
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("""
|
||||
SELECT DISTINCT b FROM BookEntity b
|
||||
LEFT JOIN b.metadata m
|
||||
@@ -309,7 +309,7 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
* Find books by series name for a library when groupUnknown=false.
|
||||
* Matches by series name, or by title/filename for books without series.
|
||||
*/
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.comicMetadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("""
|
||||
SELECT b FROM BookEntity b
|
||||
LEFT JOIN b.metadata m
|
||||
|
||||
@@ -23,8 +23,8 @@ import org.booklore.util.FileUtils;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -34,7 +34,6 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
import java.nio.file.Files;
|
||||
@@ -293,11 +292,11 @@ public class BookService {
|
||||
bookDownloadService.downloadAllBookFiles(bookId, response);
|
||||
}
|
||||
|
||||
public ResponseEntity<ByteArrayResource> getBookContent(long bookId) throws IOException {
|
||||
public ResponseEntity<Resource> getBookContent(long bookId) {
|
||||
return getBookContent(bookId, null);
|
||||
}
|
||||
|
||||
public ResponseEntity<ByteArrayResource> getBookContent(long bookId, String bookType) throws IOException {
|
||||
public ResponseEntity<Resource> getBookContent(long bookId, String bookType) {
|
||||
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
String filePath;
|
||||
if (bookType != null) {
|
||||
@@ -310,11 +309,15 @@ public class BookService {
|
||||
} else {
|
||||
filePath = FileUtils.getBookFullPath(bookEntity);
|
||||
}
|
||||
try (FileInputStream inputStream = new FileInputStream(filePath)) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(new ByteArrayResource(inputStream.readAllBytes()));
|
||||
File file = new File(filePath);
|
||||
if (!file.exists()) {
|
||||
throw ApiError.FILE_NOT_FOUND.createException(filePath);
|
||||
}
|
||||
Resource resource = new FileSystemResource(file);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.contentLength(file.length())
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.booklore.service.NotificationService;
|
||||
import org.booklore.service.book.BookQueryService;
|
||||
import org.booklore.service.metadata.extractor.CbxMetadataExtractor;
|
||||
import org.booklore.service.metadata.parser.BookParser;
|
||||
import org.booklore.service.metadata.parser.DetailedMetadataProvider;
|
||||
import org.booklore.util.FileUtils;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -82,6 +83,14 @@ public class BookMetadataService {
|
||||
}
|
||||
|
||||
|
||||
public BookMetadata getDetailedProviderMetadata(MetadataProvider provider, String providerItemId) {
|
||||
BookParser parser = getParser(provider);
|
||||
if (parser instanceof DetailedMetadataProvider detailedProvider) {
|
||||
return detailedProvider.fetchDetailedMetadata(providerItemId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private BookParser getParser(MetadataProvider provider) {
|
||||
BookParser parser = parserMap.get(provider);
|
||||
if (parser == null) {
|
||||
|
||||
@@ -88,7 +88,8 @@ public class BookMetadataUpdater {
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.areAllFieldsLocked() && hasValueChanges) {
|
||||
boolean hasLockChanges = MetadataChangeDetector.hasLockChanges(newMetadata, metadata);
|
||||
if (metadata.areAllFieldsLocked() && hasValueChanges && !hasLockChanges) {
|
||||
log.warn("All fields are locked for book ID {}. Skipping update.", bookId);
|
||||
return;
|
||||
}
|
||||
@@ -106,7 +107,7 @@ public class BookMetadataUpdater {
|
||||
updateMoodsIfNeeded(newMetadata, metadata, clearFlags, mergeMoods, replaceMode);
|
||||
updateTagsIfNeeded(newMetadata, metadata, clearFlags, mergeTags, replaceMode);
|
||||
bookReviewUpdateService.updateBookReviews(newMetadata, metadata, clearFlags, mergeCategories);
|
||||
updateThumbnailIfNeeded(bookId, bookEntity, newMetadata, metadata, updateThumbnail);
|
||||
updateThumbnailIfNeeded(bookId, bookEntity, newMetadata, metadata, updateThumbnail, bookType);
|
||||
updateAudiobookMetadataIfNeeded(bookEntity, newMetadata, metadata, clearFlags, replaceMode);
|
||||
updateComicMetadataIfNeeded(newMetadata, metadata, replaceMode);
|
||||
updateLocks(newMetadata, metadata);
|
||||
@@ -130,7 +131,9 @@ public class BookMetadataUpdater {
|
||||
}
|
||||
File file = new File(bookEntity.getFullFilePath().toUri());
|
||||
writer.saveMetadataToFile(file, metadata, thumbnailUrl, clearFlags);
|
||||
String newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath());
|
||||
String newHash = file.isDirectory()
|
||||
? FileFingerprint.generateFolderHash(bookEntity.getFullFilePath())
|
||||
: FileFingerprint.generateHash(bookEntity.getFullFilePath());
|
||||
bookEntity.setMetadataForWriteUpdatedAt(Instant.now());
|
||||
primaryFile.setCurrentHash(newHash);
|
||||
bookRepository.save(bookEntity);
|
||||
@@ -388,9 +391,11 @@ public class BookMetadataUpdater {
|
||||
|
||||
ComicMetadataEntity comic = e.getComicMetadata();
|
||||
if (comic == null) {
|
||||
if (!hasComicData(comicDto) && !hasComicLocks(comicDto)) {
|
||||
return;
|
||||
}
|
||||
comic = ComicMetadataEntity.builder()
|
||||
.bookId(e.getBookId())
|
||||
.bookMetadata(e)
|
||||
.build();
|
||||
e.setComicMetadata(comic);
|
||||
}
|
||||
@@ -402,16 +407,16 @@ public class BookMetadataUpdater {
|
||||
handleFieldUpdate(c.getVolumeNameLocked(), false, comicDto.getVolumeName(), v -> c.setVolumeName(nullIfBlank(v)), c::getVolumeName, replaceMode);
|
||||
handleFieldUpdate(c.getVolumeNumberLocked(), false, comicDto.getVolumeNumber(), c::setVolumeNumber, c::getVolumeNumber, replaceMode);
|
||||
handleFieldUpdate(c.getStoryArcLocked(), false, comicDto.getStoryArc(), v -> c.setStoryArc(nullIfBlank(v)), c::getStoryArc, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getStoryArcNumber(), c::setStoryArcNumber, c::getStoryArcNumber, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getAlternateSeries(), v -> c.setAlternateSeries(nullIfBlank(v)), c::getAlternateSeries, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getAlternateIssue(), v -> c.setAlternateIssue(nullIfBlank(v)), c::getAlternateIssue, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getImprint(), v -> c.setImprint(nullIfBlank(v)), c::getImprint, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getFormat(), v -> c.setFormat(nullIfBlank(v)), c::getFormat, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getBlackAndWhite(), c::setBlackAndWhite, c::getBlackAndWhite, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getManga(), c::setManga, c::getManga, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getReadingDirection(), v -> c.setReadingDirection(nullIfBlank(v)), c::getReadingDirection, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getWebLink(), v -> c.setWebLink(nullIfBlank(v)), c::getWebLink, replaceMode);
|
||||
handleFieldUpdate(null, false, comicDto.getNotes(), v -> c.setNotes(nullIfBlank(v)), c::getNotes, replaceMode);
|
||||
handleFieldUpdate(c.getStoryArcNumberLocked(), false, comicDto.getStoryArcNumber(), c::setStoryArcNumber, c::getStoryArcNumber, replaceMode);
|
||||
handleFieldUpdate(c.getAlternateSeriesLocked(), false, comicDto.getAlternateSeries(), v -> c.setAlternateSeries(nullIfBlank(v)), c::getAlternateSeries, replaceMode);
|
||||
handleFieldUpdate(c.getAlternateIssueLocked(), false, comicDto.getAlternateIssue(), v -> c.setAlternateIssue(nullIfBlank(v)), c::getAlternateIssue, replaceMode);
|
||||
handleFieldUpdate(c.getImprintLocked(), false, comicDto.getImprint(), v -> c.setImprint(nullIfBlank(v)), c::getImprint, replaceMode);
|
||||
handleFieldUpdate(c.getFormatLocked(), false, comicDto.getFormat(), v -> c.setFormat(nullIfBlank(v)), c::getFormat, replaceMode);
|
||||
handleFieldUpdate(c.getBlackAndWhiteLocked(), false, comicDto.getBlackAndWhite(), c::setBlackAndWhite, c::getBlackAndWhite, replaceMode);
|
||||
handleFieldUpdate(c.getMangaLocked(), false, comicDto.getManga(), c::setManga, c::getManga, replaceMode);
|
||||
handleFieldUpdate(c.getReadingDirectionLocked(), false, comicDto.getReadingDirection(), v -> c.setReadingDirection(nullIfBlank(v)), c::getReadingDirection, replaceMode);
|
||||
handleFieldUpdate(c.getWebLinkLocked(), false, comicDto.getWebLink(), v -> c.setWebLink(nullIfBlank(v)), c::getWebLink, replaceMode);
|
||||
handleFieldUpdate(c.getNotesLocked(), false, comicDto.getNotes(), v -> c.setNotes(nullIfBlank(v)), c::getNotes, replaceMode);
|
||||
|
||||
// Update relationships if not locked
|
||||
if (!Boolean.TRUE.equals(c.getCharactersLocked())) {
|
||||
@@ -423,21 +428,35 @@ public class BookMetadataUpdater {
|
||||
if (!Boolean.TRUE.equals(c.getLocationsLocked())) {
|
||||
updateComicLocations(c, comicDto.getLocations(), replaceMode);
|
||||
}
|
||||
if (!Boolean.TRUE.equals(c.getCreatorsLocked())) {
|
||||
updateComicCreators(c, comicDto, replaceMode);
|
||||
}
|
||||
updateComicCreatorsPerRole(c, comicDto, replaceMode);
|
||||
|
||||
// Update locks if provided
|
||||
if (comicDto.getIssueNumberLocked() != null) c.setIssueNumberLocked(comicDto.getIssueNumberLocked());
|
||||
if (comicDto.getVolumeNameLocked() != null) c.setVolumeNameLocked(comicDto.getVolumeNameLocked());
|
||||
if (comicDto.getVolumeNumberLocked() != null) c.setVolumeNumberLocked(comicDto.getVolumeNumberLocked());
|
||||
if (comicDto.getStoryArcLocked() != null) c.setStoryArcLocked(comicDto.getStoryArcLocked());
|
||||
if (comicDto.getStoryArcNumberLocked() != null) c.setStoryArcNumberLocked(comicDto.getStoryArcNumberLocked());
|
||||
if (comicDto.getAlternateSeriesLocked() != null) c.setAlternateSeriesLocked(comicDto.getAlternateSeriesLocked());
|
||||
if (comicDto.getAlternateIssueLocked() != null) c.setAlternateIssueLocked(comicDto.getAlternateIssueLocked());
|
||||
if (comicDto.getImprintLocked() != null) c.setImprintLocked(comicDto.getImprintLocked());
|
||||
if (comicDto.getFormatLocked() != null) c.setFormatLocked(comicDto.getFormatLocked());
|
||||
if (comicDto.getBlackAndWhiteLocked() != null) c.setBlackAndWhiteLocked(comicDto.getBlackAndWhiteLocked());
|
||||
if (comicDto.getMangaLocked() != null) c.setMangaLocked(comicDto.getMangaLocked());
|
||||
if (comicDto.getReadingDirectionLocked() != null) c.setReadingDirectionLocked(comicDto.getReadingDirectionLocked());
|
||||
if (comicDto.getWebLinkLocked() != null) c.setWebLinkLocked(comicDto.getWebLinkLocked());
|
||||
if (comicDto.getNotesLocked() != null) c.setNotesLocked(comicDto.getNotesLocked());
|
||||
if (comicDto.getCreatorsLocked() != null) c.setCreatorsLocked(comicDto.getCreatorsLocked());
|
||||
if (comicDto.getPencillersLocked() != null) c.setPencillersLocked(comicDto.getPencillersLocked());
|
||||
if (comicDto.getInkersLocked() != null) c.setInkersLocked(comicDto.getInkersLocked());
|
||||
if (comicDto.getColoristsLocked() != null) c.setColoristsLocked(comicDto.getColoristsLocked());
|
||||
if (comicDto.getLetterersLocked() != null) c.setLetterersLocked(comicDto.getLetterersLocked());
|
||||
if (comicDto.getCoverArtistsLocked() != null) c.setCoverArtistsLocked(comicDto.getCoverArtistsLocked());
|
||||
if (comicDto.getEditorsLocked() != null) c.setEditorsLocked(comicDto.getEditorsLocked());
|
||||
if (comicDto.getCharactersLocked() != null) c.setCharactersLocked(comicDto.getCharactersLocked());
|
||||
if (comicDto.getTeamsLocked() != null) c.setTeamsLocked(comicDto.getTeamsLocked());
|
||||
if (comicDto.getLocationsLocked() != null) c.setLocationsLocked(comicDto.getLocationsLocked());
|
||||
|
||||
comicMetadataRepository.save(c);
|
||||
e.setComicMetadata(comicMetadataRepository.save(c));
|
||||
|
||||
comicCharacterRepository.deleteOrphaned();
|
||||
comicTeamRepository.deleteOrphaned();
|
||||
@@ -445,6 +464,59 @@ public class BookMetadataUpdater {
|
||||
comicCreatorRepository.deleteOrphaned();
|
||||
}
|
||||
|
||||
private boolean hasComicData(ComicMetadata dto) {
|
||||
return StringUtils.hasText(dto.getIssueNumber())
|
||||
|| StringUtils.hasText(dto.getVolumeName())
|
||||
|| dto.getVolumeNumber() != null
|
||||
|| StringUtils.hasText(dto.getStoryArc())
|
||||
|| dto.getStoryArcNumber() != null
|
||||
|| StringUtils.hasText(dto.getAlternateSeries())
|
||||
|| StringUtils.hasText(dto.getAlternateIssue())
|
||||
|| StringUtils.hasText(dto.getImprint())
|
||||
|| StringUtils.hasText(dto.getFormat())
|
||||
|| dto.getBlackAndWhite() != null
|
||||
|| dto.getManga() != null
|
||||
|| StringUtils.hasText(dto.getReadingDirection())
|
||||
|| StringUtils.hasText(dto.getWebLink())
|
||||
|| StringUtils.hasText(dto.getNotes())
|
||||
|| (dto.getCharacters() != null && !dto.getCharacters().isEmpty())
|
||||
|| (dto.getTeams() != null && !dto.getTeams().isEmpty())
|
||||
|| (dto.getLocations() != null && !dto.getLocations().isEmpty())
|
||||
|| (dto.getPencillers() != null && !dto.getPencillers().isEmpty())
|
||||
|| (dto.getInkers() != null && !dto.getInkers().isEmpty())
|
||||
|| (dto.getColorists() != null && !dto.getColorists().isEmpty())
|
||||
|| (dto.getLetterers() != null && !dto.getLetterers().isEmpty())
|
||||
|| (dto.getCoverArtists() != null && !dto.getCoverArtists().isEmpty())
|
||||
|| (dto.getEditors() != null && !dto.getEditors().isEmpty());
|
||||
}
|
||||
|
||||
private boolean hasComicLocks(ComicMetadata dto) {
|
||||
return Boolean.TRUE.equals(dto.getIssueNumberLocked())
|
||||
|| Boolean.TRUE.equals(dto.getVolumeNameLocked())
|
||||
|| Boolean.TRUE.equals(dto.getVolumeNumberLocked())
|
||||
|| Boolean.TRUE.equals(dto.getStoryArcLocked())
|
||||
|| Boolean.TRUE.equals(dto.getStoryArcNumberLocked())
|
||||
|| Boolean.TRUE.equals(dto.getAlternateSeriesLocked())
|
||||
|| Boolean.TRUE.equals(dto.getAlternateIssueLocked())
|
||||
|| Boolean.TRUE.equals(dto.getImprintLocked())
|
||||
|| Boolean.TRUE.equals(dto.getFormatLocked())
|
||||
|| Boolean.TRUE.equals(dto.getBlackAndWhiteLocked())
|
||||
|| Boolean.TRUE.equals(dto.getMangaLocked())
|
||||
|| Boolean.TRUE.equals(dto.getReadingDirectionLocked())
|
||||
|| Boolean.TRUE.equals(dto.getWebLinkLocked())
|
||||
|| Boolean.TRUE.equals(dto.getNotesLocked())
|
||||
|| Boolean.TRUE.equals(dto.getCreatorsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getPencillersLocked())
|
||||
|| Boolean.TRUE.equals(dto.getInkersLocked())
|
||||
|| Boolean.TRUE.equals(dto.getColoristsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getLetterersLocked())
|
||||
|| Boolean.TRUE.equals(dto.getCoverArtistsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getEditorsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getCharactersLocked())
|
||||
|| Boolean.TRUE.equals(dto.getTeamsLocked())
|
||||
|| Boolean.TRUE.equals(dto.getLocationsLocked());
|
||||
}
|
||||
|
||||
private void updateComicCharacters(ComicMetadataEntity c, Set<String> characters, MetadataReplaceMode mode) {
|
||||
if (characters == null || characters.isEmpty()) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL) {
|
||||
@@ -511,38 +583,43 @@ public class BookMetadataUpdater {
|
||||
.forEach(entity -> c.getLocations().add(entity));
|
||||
}
|
||||
|
||||
private void updateComicCreators(ComicMetadataEntity c, ComicMetadata dto, MetadataReplaceMode mode) {
|
||||
private void updateComicCreatorsPerRole(ComicMetadataEntity c, ComicMetadata dto, MetadataReplaceMode mode) {
|
||||
if (c.getCreatorMappings() == null) {
|
||||
c.setCreatorMappings(new HashSet<>());
|
||||
}
|
||||
|
||||
boolean hasNewCreators = (dto.getPencillers() != null && !dto.getPencillers().isEmpty()) ||
|
||||
(dto.getInkers() != null && !dto.getInkers().isEmpty()) ||
|
||||
(dto.getColorists() != null && !dto.getColorists().isEmpty()) ||
|
||||
(dto.getLetterers() != null && !dto.getLetterers().isEmpty()) ||
|
||||
(dto.getCoverArtists() != null && !dto.getCoverArtists().isEmpty()) ||
|
||||
(dto.getEditors() != null && !dto.getEditors().isEmpty());
|
||||
updateCreatorRole(c, dto.getPencillers(), ComicCreatorRole.PENCILLER, c.getPencillersLocked(), mode);
|
||||
updateCreatorRole(c, dto.getInkers(), ComicCreatorRole.INKER, c.getInkersLocked(), mode);
|
||||
updateCreatorRole(c, dto.getColorists(), ComicCreatorRole.COLORIST, c.getColoristsLocked(), mode);
|
||||
updateCreatorRole(c, dto.getLetterers(), ComicCreatorRole.LETTERER, c.getLetterersLocked(), mode);
|
||||
updateCreatorRole(c, dto.getCoverArtists(), ComicCreatorRole.COVER_ARTIST, c.getCoverArtistsLocked(), mode);
|
||||
updateCreatorRole(c, dto.getEditors(), ComicCreatorRole.EDITOR, c.getEditorsLocked(), mode);
|
||||
}
|
||||
|
||||
if (!hasNewCreators) {
|
||||
private void updateCreatorRole(ComicMetadataEntity c, Set<String> names, ComicCreatorRole role, Boolean locked, MetadataReplaceMode mode) {
|
||||
if (Boolean.TRUE.equals(locked)) return;
|
||||
|
||||
boolean hasNewNames = names != null && !names.isEmpty();
|
||||
Set<ComicCreatorMappingEntity> existingForRole = c.getCreatorMappings().stream()
|
||||
.filter(m -> m.getRole() == role)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (!hasNewNames) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL) {
|
||||
c.getCreatorMappings().clear();
|
||||
c.getCreatorMappings().removeAll(existingForRole);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
|
||||
c.getCreatorMappings().clear();
|
||||
}
|
||||
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getCreatorMappings().isEmpty()) {
|
||||
if (mode == MetadataReplaceMode.REPLACE_MISSING && !existingForRole.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
addCreatorsWithRole(c, dto.getPencillers(), ComicCreatorRole.PENCILLER);
|
||||
addCreatorsWithRole(c, dto.getInkers(), ComicCreatorRole.INKER);
|
||||
addCreatorsWithRole(c, dto.getColorists(), ComicCreatorRole.COLORIST);
|
||||
addCreatorsWithRole(c, dto.getLetterers(), ComicCreatorRole.LETTERER);
|
||||
addCreatorsWithRole(c, dto.getCoverArtists(), ComicCreatorRole.COVER_ARTIST);
|
||||
addCreatorsWithRole(c, dto.getEditors(), ComicCreatorRole.EDITOR);
|
||||
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED || mode == null) {
|
||||
c.getCreatorMappings().removeAll(existingForRole);
|
||||
}
|
||||
|
||||
addCreatorsWithRole(c, names, role);
|
||||
}
|
||||
|
||||
private void addCreatorsWithRole(ComicMetadataEntity comic, Set<String> names, ComicCreatorRole role) {
|
||||
@@ -562,16 +639,22 @@ public class BookMetadataUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateThumbnailIfNeeded(long bookId, BookEntity bookEntity, BookMetadata m, BookMetadataEntity e, boolean set) {
|
||||
private void updateThumbnailIfNeeded(long bookId, BookEntity bookEntity, BookMetadata m, BookMetadataEntity e, boolean set, BookFileType bookType) {
|
||||
if (Boolean.TRUE.equals(e.getCoverLocked())) {
|
||||
return;
|
||||
}
|
||||
if (!set) return;
|
||||
if (!StringUtils.hasText(m.getThumbnailUrl()) || isLocalOrPrivateUrl(m.getThumbnailUrl())) return;
|
||||
try {
|
||||
fileService.createThumbnailFromUrl(bookId, m.getThumbnailUrl());
|
||||
if (bookType == BookFileType.AUDIOBOOK) {
|
||||
if (Boolean.TRUE.equals(e.getAudiobookCoverLocked())) return;
|
||||
fileService.createAudiobookThumbnailFromUrl(bookId, m.getThumbnailUrl());
|
||||
bookEntity.getMetadata().setAudiobookCoverUpdatedOn(Instant.now());
|
||||
} else {
|
||||
fileService.createThumbnailFromUrl(bookId, m.getThumbnailUrl());
|
||||
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
|
||||
}
|
||||
bookEntity.setBookCoverHash(BookCoverUtils.generateCoverHash());
|
||||
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to download cover for book {}: {}", bookId, ex.getMessage());
|
||||
}
|
||||
|
||||
@@ -614,6 +614,10 @@ public class MetadataRefreshService {
|
||||
metadata.setComicvineId(existingMetadata.getComicvineId());
|
||||
}
|
||||
|
||||
if (metadataMap.containsKey(Comicvine) && metadataMap.get(Comicvine).getComicMetadata() != null) {
|
||||
metadata.setComicMetadata(metadataMap.get(Comicvine).getComicMetadata());
|
||||
}
|
||||
|
||||
if (enabledFields.isLubimyczytacId()) {
|
||||
if (metadataMap.containsKey(Lubimyczytac)) {
|
||||
metadata.setLubimyczytacId(metadataMap.get(Lubimyczytac).getLubimyczytacId());
|
||||
|
||||
@@ -31,7 +31,7 @@ import java.util.stream.Collectors;
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class AmazonBookParser implements BookParser {
|
||||
public class AmazonBookParser implements BookParser, DetailedMetadataProvider {
|
||||
|
||||
private static final Pattern TRAILING_BR_TAGS_PATTERN = Pattern.compile("(\\s*<br\\s*/?>\\s*)+$");
|
||||
private static final Pattern LEADING_BR_TAGS_PATTERN = Pattern.compile("^(\\s*<br\\s*/?>\\s*)+");
|
||||
@@ -104,32 +104,70 @@ public class AmazonBookParser implements BookParser {
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
LinkedList<String> amazonBookIds = Optional.ofNullable(getAmazonBookIds(book, fetchMetadataRequest))
|
||||
.map(list -> list.stream()
|
||||
.limit(COUNT_DETAILED_METADATA_TO_GET)
|
||||
.collect(Collectors.toCollection(LinkedList::new)))
|
||||
.orElse(new LinkedList<>());
|
||||
if (amazonBookIds.isEmpty()) {
|
||||
return null;
|
||||
String queryUrl = buildQueryUrl(fetchMetadataRequest, book);
|
||||
if (queryUrl == null) {
|
||||
log.error("Query URL is null, cannot proceed.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<BookMetadata> fetchedBookMetadata = new ArrayList<>();
|
||||
for (String amazonBookId : amazonBookIds) {
|
||||
if (amazonBookId == null || amazonBookId.isBlank()) {
|
||||
log.debug("Skipping null or blank Amazon book ID.");
|
||||
continue;
|
||||
}
|
||||
BookMetadata metadata = getBookMetadata(amazonBookId);
|
||||
if (metadata == null) {
|
||||
log.debug("Skipping null metadata for ID: {}", amazonBookId);
|
||||
continue;
|
||||
}
|
||||
if (metadata.getTitle() == null || metadata.getTitle().isBlank() || metadata.getAuthors() == null || metadata.getAuthors().isEmpty()) {
|
||||
log.debug("Skipping metadata with missing title or author for ID: {}", amazonBookId);
|
||||
continue;
|
||||
}
|
||||
fetchedBookMetadata.add(metadata);
|
||||
try {
|
||||
Document doc = fetchDocument(queryUrl);
|
||||
return extractSearchPreviews(doc);
|
||||
} catch (AmazonAntiScrapingException e) {
|
||||
log.debug("Aborting Amazon search due to anti-scraping (503).");
|
||||
return Collections.emptyList();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch Amazon search results: {}", e.getMessage(), e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return fetchedBookMetadata;
|
||||
}
|
||||
|
||||
private List<BookMetadata> extractSearchPreviews(Document doc) {
|
||||
Element searchResults = doc.select("span[data-component-type=s-search-results]").first();
|
||||
if (searchResults == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Elements items = searchResults.select("div[role=listitem][data-index]");
|
||||
List<BookMetadata> previews = new ArrayList<>();
|
||||
for (Element item : items) {
|
||||
if (previews.size() >= COUNT_DETAILED_METADATA_TO_GET) break;
|
||||
|
||||
if (item.text().contains("Collects books from")) continue;
|
||||
|
||||
Element titleDiv = item.selectFirst("div[data-cy=title-recipe]");
|
||||
if (titleDiv == null) continue;
|
||||
|
||||
String titleText = titleDiv.text().trim();
|
||||
if (titleText.isEmpty()) continue;
|
||||
|
||||
String lowerTitle = titleText.toLowerCase();
|
||||
if (lowerTitle.contains("books set") || lowerTitle.contains("box set")
|
||||
|| lowerTitle.contains("collection set") || lowerTitle.contains("summary & study guide")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String asin = extractAmazonBookId(item);
|
||||
if (asin == null || asin.isBlank()) continue;
|
||||
|
||||
String thumbnailUrl = null;
|
||||
Element img = item.selectFirst("img.s-image");
|
||||
if (img != null) {
|
||||
thumbnailUrl = img.attr("src");
|
||||
if (thumbnailUrl.isBlank()) thumbnailUrl = null;
|
||||
}
|
||||
|
||||
previews.add(BookMetadata.builder()
|
||||
.asin(asin)
|
||||
.title(titleText)
|
||||
.thumbnailUrl(thumbnailUrl)
|
||||
.provider(MetadataProvider.Amazon)
|
||||
.build());
|
||||
}
|
||||
return previews;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchDetailedMetadata(String asin) {
|
||||
return getBookMetadata(asin);
|
||||
}
|
||||
|
||||
private LinkedList<String> getAmazonBookIds(Book book, FetchMetadataRequest request) {
|
||||
|
||||
@@ -33,10 +33,9 @@ import java.util.stream.Collectors;
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class AudibleParser implements BookParser {
|
||||
public class AudibleParser implements BookParser, DetailedMetadataProvider {
|
||||
|
||||
private static final long MIN_REQUEST_INTERVAL_MS = 1500;
|
||||
private static final int MAX_DETAILED_RESULTS = 3;
|
||||
private static final String DEFAULT_DOMAIN = "com";
|
||||
|
||||
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^\\p{L}\\p{M}0-9]");
|
||||
@@ -75,34 +74,125 @@ public class AudibleParser implements BookParser {
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
List<String> audibleIds = Optional.ofNullable(getAudibleIds(book, fetchMetadataRequest))
|
||||
.map(list -> list.stream()
|
||||
.limit(MAX_DETAILED_RESULTS)
|
||||
.collect(Collectors.toList()))
|
||||
.orElse(new ArrayList<>());
|
||||
|
||||
if (audibleIds.isEmpty()) {
|
||||
return null;
|
||||
String queryUrl = buildQueryUrl(fetchMetadataRequest, book);
|
||||
if (queryUrl == null) {
|
||||
log.error("Query URL is null, cannot proceed with Audible search.");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<BookMetadata> fetchedMetadata = new ArrayList<>();
|
||||
for (String audibleId : audibleIds) {
|
||||
if (audibleId == null || audibleId.isBlank()) {
|
||||
log.debug("Skipping null or blank Audible ID.");
|
||||
continue;
|
||||
}
|
||||
BookMetadata metadata = getBookMetadata(audibleId);
|
||||
if (metadata == null) {
|
||||
log.debug("Skipping null metadata for Audible ID: {}", audibleId);
|
||||
continue;
|
||||
}
|
||||
if (metadata.getTitle() == null || metadata.getTitle().isBlank()) {
|
||||
log.debug("Skipping metadata with missing title for Audible ID: {}", audibleId);
|
||||
continue;
|
||||
}
|
||||
fetchedMetadata.add(metadata);
|
||||
try {
|
||||
enforceRateLimit();
|
||||
Document doc = fetchDocument(queryUrl);
|
||||
return extractSearchPreviews(doc);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch Audible search results: {}", e.getMessage(), e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return fetchedMetadata;
|
||||
}
|
||||
|
||||
private List<BookMetadata> extractSearchPreviews(Document doc) {
|
||||
Elements allLinks = doc.select("a[href*='/pd/']");
|
||||
List<BookMetadata> previews = new ArrayList<>();
|
||||
Set<String> seenAsins = new HashSet<>();
|
||||
|
||||
for (Element link : allLinks) {
|
||||
String href = link.attr("href");
|
||||
Matcher matcher = ASIN_PATTERN.matcher(href);
|
||||
if (!matcher.find()) continue;
|
||||
|
||||
String asin = matcher.group(1);
|
||||
if (seenAsins.contains(asin)) continue;
|
||||
|
||||
String title = link.text().trim();
|
||||
if (title.isEmpty()) continue;
|
||||
|
||||
seenAsins.add(asin);
|
||||
|
||||
Element container = findProductContainer(link);
|
||||
|
||||
String thumbnailUrl = extractPreviewThumbnail(container, link);
|
||||
Set<String> authors = extractPreviewAuthors(container);
|
||||
String narrator = extractPreviewNarrator(container);
|
||||
|
||||
BookMetadata.BookMetadataBuilder builder = BookMetadata.builder()
|
||||
.audibleId(asin)
|
||||
.title(title)
|
||||
.thumbnailUrl(thumbnailUrl)
|
||||
.provider(MetadataProvider.Audible);
|
||||
|
||||
if (!authors.isEmpty()) {
|
||||
builder.authors(authors);
|
||||
}
|
||||
if (narrator != null) {
|
||||
builder.narrator(narrator);
|
||||
}
|
||||
|
||||
previews.add(builder.build());
|
||||
}
|
||||
return previews;
|
||||
}
|
||||
|
||||
private Element findProductContainer(Element titleLink) {
|
||||
Element current = titleLink.parent();
|
||||
for (int i = 0; i < 8 && current != null; i++) {
|
||||
if (current.selectFirst("img[src*='images/I/']") != null) {
|
||||
return current;
|
||||
}
|
||||
current = current.parent();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractPreviewThumbnail(Element container, Element titleLink) {
|
||||
if (container != null) {
|
||||
Element img = container.selectFirst("img[src*='images/I/']");
|
||||
if (img != null) {
|
||||
String src = img.attr("src");
|
||||
if (!src.isBlank()) return src;
|
||||
}
|
||||
}
|
||||
Element img = titleLink.selectFirst("img");
|
||||
if (img == null && titleLink.parent() != null) {
|
||||
img = titleLink.parent().selectFirst("img");
|
||||
}
|
||||
if (img != null) {
|
||||
String src = img.attr("src");
|
||||
if (!src.isBlank()) return src;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Set<String> extractPreviewAuthors(Element container) {
|
||||
Set<String> authors = new LinkedHashSet<>();
|
||||
if (container == null) return authors;
|
||||
|
||||
for (Element authorLink : container.select("a[href*='/author/']")) {
|
||||
String name = authorLink.text().trim();
|
||||
if (!name.isEmpty()) {
|
||||
authors.add(name);
|
||||
}
|
||||
}
|
||||
return authors;
|
||||
}
|
||||
|
||||
private String extractPreviewNarrator(Element container) {
|
||||
if (container == null) return null;
|
||||
|
||||
for (Element el : container.getElementsContainingOwnText("Narrated by:")) {
|
||||
Element parent = el.parent();
|
||||
if (parent == null) continue;
|
||||
String text = parent.text();
|
||||
int idx = text.indexOf("Narrated by:");
|
||||
if (idx >= 0) {
|
||||
String narrator = text.substring(idx + "Narrated by:".length()).trim();
|
||||
if (!narrator.isEmpty()) return narrator;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchDetailedMetadata(String audibleId) {
|
||||
return getBookMetadata(audibleId);
|
||||
}
|
||||
|
||||
private List<String> getAudibleIds(Book book, FetchMetadataRequest request) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.Book;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.model.dto.request.FetchMetadataRequest;
|
||||
import org.booklore.model.dto.response.comicvineapi.Comic;
|
||||
import org.booklore.model.dto.response.comicvineapi.ComicvineApiResponse;
|
||||
@@ -33,7 +34,7 @@ import java.util.stream.Collectors;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ComicvineBookParser implements BookParser {
|
||||
public class ComicvineBookParser implements BookParser, DetailedMetadataProvider {
|
||||
|
||||
private static final String COMICVINE_URL = "https://comicvine.gamespot.com/api/";
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
@@ -45,12 +46,10 @@ public class ComicvineBookParser implements BookParser {
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
private static final Pattern SPECIAL_ISSUE_PATTERN = Pattern.compile("(annual|special|one-?shot)\\s+(\\d+)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern YEAR_PATTERN = Pattern.compile("\\(?(\\d{4})\\)?");
|
||||
private static final Pattern COMICVINE_ID_PATTERN = Pattern.compile("/(\\d+)/?$");
|
||||
private static final long MIN_REQUEST_INTERVAL_MS = 2000;
|
||||
|
||||
// Field lists to minimize API calls by getting all useful data in one request
|
||||
private static final String VOLUME_FIELDS = "id,name,publisher,start_year,count_of_issues,description,deck,image,site_detail_url,aliases,first_issue,last_issue";
|
||||
private static final String ISSUE_LIST_FIELDS = "api_detail_url,cover_date,store_date,description,deck,id,image,issue_number,name,volume,site_detail_url,aliases,person_credits";
|
||||
private static final String ISSUE_LIST_FIELDS = "api_detail_url,cover_date,store_date,description,deck,id,image,issue_number,name,volume,site_detail_url,aliases,person_credits,character_credits,team_credits,story_arc_credits,location_credits";
|
||||
private static final String ISSUE_DETAIL_FIELDS = "api_detail_url,cover_date,store_date,description,deck,id,image,issue_number,name,person_credits,volume,site_detail_url,aliases,character_credits,team_credits,story_arc_credits,location_credits";
|
||||
private static final String SEARCH_FIELDS = "api_detail_url,cover_date,store_date,description,deck,id,image,issue_number,name,publisher,volume,site_detail_url,resource_type,start_year,count_of_issues,aliases,person_credits";
|
||||
private static final Pattern ISSUE_NUMBER_PATTERN = Pattern.compile("issue\\s*#?\\d+");
|
||||
@@ -78,17 +77,15 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
|
||||
boolean isExpired() {
|
||||
return System.currentTimeMillis() - timestamp > 600_000; // 10 minutes cache
|
||||
return System.currentTimeMillis() - timestamp > 600_000;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BookMetadata> fetchMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
// 1. Try ISBN Search
|
||||
String isbn = ParserUtils.cleanIsbn(fetchMetadataRequest.getIsbn());
|
||||
if (isbn != null && !isbn.isEmpty()) {
|
||||
log.info("Comicvine: Searching by ISBN: {}", isbn);
|
||||
// ComicVine "search" endpoint with "query" parameter performs a general search.
|
||||
List<BookMetadata> results = searchGeneral(isbn);
|
||||
if (!results.isEmpty()) return results;
|
||||
log.info("Comicvine: ISBN search returned no results, falling back to Title/Term.");
|
||||
@@ -105,13 +102,21 @@ public class ComicvineBookParser implements BookParser {
|
||||
@Override
|
||||
public BookMetadata fetchTopMetadata(Book book, FetchMetadataRequest fetchMetadataRequest) {
|
||||
List<BookMetadata> metadataList = fetchMetadata(book, fetchMetadataRequest);
|
||||
return metadataList.isEmpty() ? null : metadataList.getFirst();
|
||||
if (metadataList.isEmpty()) return null;
|
||||
|
||||
BookMetadata top = metadataList.getFirst();
|
||||
if (top.getComicvineId() != null && (top.getComicMetadata() == null || !hasComicDetails(top.getComicMetadata()))) {
|
||||
BookMetadata detailed = fetchDetailedMetadata(top.getComicvineId());
|
||||
if (detailed != null && detailed.getComicMetadata() != null) {
|
||||
top.setComicMetadata(detailed.getComicMetadata());
|
||||
}
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
public List<BookMetadata> getMetadataListByTerm(String term) {
|
||||
SeriesAndIssue seriesAndIssue = extractSeriesAndIssue(term);
|
||||
|
||||
// Strategy 1: Precise structured search if issue number is present (most efficient)
|
||||
if (seriesAndIssue.issue() != null) {
|
||||
log.info("Attempting structured search for Series: '{}', Issue: '{}', Year: '{}', Type: '{}'",
|
||||
seriesAndIssue.series(), seriesAndIssue.issue(), seriesAndIssue.year(), seriesAndIssue.issueType());
|
||||
@@ -120,21 +125,18 @@ public class ComicvineBookParser implements BookParser {
|
||||
return preciseResults;
|
||||
}
|
||||
log.info("Structured search yielded no results, trying alternative strategies.");
|
||||
|
||||
// Strategy 2: Try with cleaned series name variations
|
||||
|
||||
List<BookMetadata> alternativeResults = tryAlternativeSeriesNames(seriesAndIssue);
|
||||
if (!alternativeResults.isEmpty()) {
|
||||
return alternativeResults;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: General search (fallback)
|
||||
List<BookMetadata> results = searchGeneral(term);
|
||||
if (!results.isEmpty()) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Strategy 4: If general search failed with structured match, try modified term
|
||||
if (seriesAndIssue.issue() != null && seriesAndIssue.remainder() != null && !seriesAndIssue.remainder().isBlank()) {
|
||||
String modifiedTerm = seriesAndIssue.series() + " " + seriesAndIssue.remainder();
|
||||
if (seriesAndIssue.year() != null) {
|
||||
@@ -205,7 +207,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// Sort volumes by relevance using scoring
|
||||
volumes.sort((v1, v2) -> {
|
||||
int score1 = calculateVolumeScore(v1, finalSeriesName, normalizedIssue, extractedYear);
|
||||
int score2 = calculateVolumeScore(v2, finalSeriesName, normalizedIssue, extractedYear);
|
||||
@@ -213,7 +214,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
});
|
||||
|
||||
List<BookMetadata> results = new ArrayList<>();
|
||||
// Limit to top 3 volumes to minimize API calls
|
||||
int limit = Math.min(volumes.size(), 3);
|
||||
|
||||
for (int i = 0; i < limit; i++) {
|
||||
@@ -224,9 +224,8 @@ public class ComicvineBookParser implements BookParser {
|
||||
if (!issues.isEmpty()) {
|
||||
results.addAll(issues);
|
||||
|
||||
// If we found a result in a high-score volume (e.g. year matched), stop.
|
||||
if (extractedYear != null && matchesYear(volume, extractedYear)) {
|
||||
log.info("Found match in year-aligned volume '{}' ({}) . Stopping further volume searches.", volume.getName(), volume.getStartYear());
|
||||
log.info("Found match in year-aligned volume '{}' ({}). Stopping further volume searches.", volume.getName(), volume.getStartYear());
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -283,10 +282,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for volumes using the /volumes endpoint with filter (more efficient than /search)
|
||||
* Falls back to /search if /volumes returns no results
|
||||
*/
|
||||
private List<Comic> searchVolumes(String seriesName) {
|
||||
String cacheKey = seriesName.toLowerCase();
|
||||
CachedVolumes cached = volumeCache.get(cacheKey);
|
||||
@@ -298,7 +293,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
String apiToken = getApiToken();
|
||||
if (apiToken == null) return Collections.emptyList();
|
||||
|
||||
// First try using /volumes endpoint with name filter (more efficient, allows up to 100 results)
|
||||
URI uri = UriComponentsBuilder.fromUriString(COMICVINE_URL)
|
||||
.path("/volumes/")
|
||||
.queryParam("api_key", apiToken)
|
||||
@@ -313,7 +307,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
ComicvineApiResponse response = sendRequest(uri, ComicvineApiResponse.class);
|
||||
List<Comic> volumes = response != null && response.getResults() != null ? response.getResults() : Collections.emptyList();
|
||||
|
||||
// If no results, try search endpoint as fallback
|
||||
if (volumes.isEmpty()) {
|
||||
log.debug("No volumes found via /volumes filter, trying /search for '{}'", seriesName);
|
||||
volumes = searchVolumesViaSearch(seriesName, apiToken);
|
||||
@@ -346,9 +339,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
return response != null && response.getResults() != null ? response.getResults() : Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for issues in a specific volume. Now passes the full volume object to enrich metadata.
|
||||
*/
|
||||
private List<BookMetadata> searchIssuesInVolume(Comic volume, String issueNumber) {
|
||||
String apiToken = getApiToken();
|
||||
if (apiToken == null) return Collections.emptyList();
|
||||
@@ -357,7 +347,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
log.debug("searchIssuesInVolume: volumeId='{}', original='{}', normalized='{}'",
|
||||
volume.getId(), issueNumber, normalizedIssue);
|
||||
|
||||
// Use /issues endpoint with filter - can return up to 100 results but we only need matching issues
|
||||
URI uri = UriComponentsBuilder.fromUriString(COMICVINE_URL)
|
||||
.path("/issues/")
|
||||
.queryParam("api_key", apiToken)
|
||||
@@ -379,18 +368,24 @@ public class ComicvineBookParser implements BookParser {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (firstIssue.getPersonCredits() != null && !firstIssue.getPersonCredits().isEmpty()) {
|
||||
log.debug("Issue {} has person_credits, using basic metadata (saving 1 API call)", firstIssue.getId());
|
||||
boolean hasCredits = hasAnyCredits(firstIssue.getPersonCredits(), firstIssue.getCharacterCredits(),
|
||||
firstIssue.getTeamCredits(), firstIssue.getStoryArcCredits(), firstIssue.getLocationCredits());
|
||||
|
||||
if (hasCredits) {
|
||||
log.debug("Issue {} has credits from list endpoint, using directly", firstIssue.getId());
|
||||
return Collections.singletonList(convertToBookMetadata(firstIssue, volume));
|
||||
}
|
||||
|
||||
BookMetadata detailed = fetchIssueDetails(firstIssue.getId(), volume);
|
||||
|
||||
return Collections.singletonList(Objects.requireNonNullElseGet(detailed, () -> convertToBookMetadata(firstIssue, volume)));
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchDetailedMetadata(String comicvineId) {
|
||||
return fetchIssueDetails(Integer.parseInt(comicvineId), null);
|
||||
}
|
||||
|
||||
private BookMetadata fetchIssueDetails(int issueId, Comic volumeContext) {
|
||||
String apiToken = getApiToken();
|
||||
@@ -406,7 +401,7 @@ public class ComicvineBookParser implements BookParser {
|
||||
|
||||
ComicvineIssueResponse response = sendRequest(uri, ComicvineIssueResponse.class);
|
||||
if (response != null && response.getResults() != null) {
|
||||
return convertToBookMetadata(response.getResults(), volumeContext);
|
||||
return convertToBookMetadata(response.getResults(), issueId, volumeContext);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -540,7 +535,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private String extractEndpointFromUri(URI uri) {
|
||||
String path = uri.getPath();
|
||||
if (path == null || path.isEmpty()) return "unknown";
|
||||
@@ -549,7 +543,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
int lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
|
||||
String segment = path.substring(lastSlash + 1);
|
||||
// If segment looks like an ID (4000-12345), include the parent
|
||||
if (ID_FORMAT_PATTERN.matcher(segment).matches()) {
|
||||
int prevSlash = path.lastIndexOf('/', lastSlash - 1);
|
||||
if (prevSlash >= 0) {
|
||||
@@ -562,12 +555,10 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
|
||||
private BookMetadata convertToBookMetadata(Comic comic, Comic volumeContext) {
|
||||
// Check if this is a Volume (from search results)
|
||||
if ("volume".equalsIgnoreCase(comic.getResourceType())) {
|
||||
return buildVolumeMetadata(comic);
|
||||
}
|
||||
|
||||
// Get publisher from volume context if available
|
||||
String publisher = null;
|
||||
Integer seriesTotal = null;
|
||||
if (volumeContext != null) {
|
||||
@@ -577,41 +568,45 @@ public class ComicvineBookParser implements BookParser {
|
||||
seriesTotal = volumeContext.getCountOfIssues();
|
||||
}
|
||||
|
||||
String volumeName = comic.getVolume() != null ? comic.getVolume().getName() : null;
|
||||
Set<String> authors = extractAuthors(comic.getPersonCredits());
|
||||
Set<String> tags = extractTags(comic);
|
||||
String formattedTitle = formatTitle(comic.getVolume() != null ? comic.getVolume().getName() : null, comic.getIssueNumber(), comic.getName());
|
||||
|
||||
String formattedTitle = formatTitle(volumeName, comic.getIssueNumber(), comic.getName());
|
||||
String dateToUse = comic.getStoreDate() != null ? comic.getStoreDate() : comic.getCoverDate();
|
||||
|
||||
|
||||
String description = comic.getDescription();
|
||||
if ((description == null || description.isEmpty()) && comic.getDeck() != null) {
|
||||
description = comic.getDeck();
|
||||
}
|
||||
|
||||
ComicMetadata comicMetadata = buildComicMetadata(
|
||||
comic.getIssueNumber(), volumeName,
|
||||
comic.getPersonCredits(), comic.getCharacterCredits(),
|
||||
comic.getTeamCredits(), comic.getStoryArcCredits(),
|
||||
comic.getLocationCredits(), comic.getSiteDetailUrl());
|
||||
|
||||
BookMetadata metadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Comicvine)
|
||||
.comicvineId(String.valueOf(comic.getId()))
|
||||
.title(formattedTitle)
|
||||
.authors(authors)
|
||||
.tags(tags.isEmpty() ? null : tags)
|
||||
.thumbnailUrl(comic.getImage() != null ? comic.getImage().getMediumUrl() : null)
|
||||
.description(description)
|
||||
.seriesName(comic.getVolume() != null ? comic.getVolume().getName() : null)
|
||||
.seriesName(volumeName)
|
||||
.seriesNumber(safeParseFloat(comic.getIssueNumber()))
|
||||
.seriesTotal(seriesTotal)
|
||||
.publisher(publisher)
|
||||
.publishedDate(safeParseDate(dateToUse))
|
||||
.externalUrl(comic.getSiteDetailUrl())
|
||||
.comicMetadata(comicMetadata)
|
||||
.build();
|
||||
|
||||
|
||||
if (metadata.getSeriesName() == null || metadata.getSeriesNumber() == null) {
|
||||
log.warn("Incomplete metadata for issue {}: missing series name or number", metadata.getComicvineId());
|
||||
}
|
||||
|
||||
if (metadata.getAuthors().isEmpty()) {
|
||||
log.debug("No authors found for issue {} ({})", metadata.getComicvineId(), metadata.getTitle());
|
||||
}
|
||||
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@@ -633,133 +628,129 @@ public class ComicvineBookParser implements BookParser {
|
||||
.build();
|
||||
}
|
||||
|
||||
private BookMetadata convertToBookMetadata(ComicvineIssueResponse.IssueResults issue, Comic volumeContext) {
|
||||
// Extract ID from api_detail_url: "https://comicvine.gamespot.com/api/issue/4000-12345/"
|
||||
String comicvineId = null;
|
||||
if (issue.getApiDetailUrl() != null) {
|
||||
Matcher matcher = COMICVINE_ID_PATTERN.matcher(issue.getApiDetailUrl());
|
||||
if (matcher.find()) {
|
||||
comicvineId = matcher.group(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Get publisher and series total from volume context
|
||||
String publisher = null;
|
||||
Integer seriesTotal = null;
|
||||
if (volumeContext != null) {
|
||||
if (volumeContext.getPublisher() != null) {
|
||||
publisher = volumeContext.getPublisher().getName();
|
||||
}
|
||||
seriesTotal = volumeContext.getCountOfIssues();
|
||||
}
|
||||
|
||||
Set<String> authors = extractAuthors(issue.getPersonCredits());
|
||||
Set<String> tags = extractTagsFromIssue(issue);
|
||||
String formattedTitle = formatTitle(issue.getVolume() != null ? issue.getVolume().getName() : null, issue.getIssueNumber(), issue.getName());
|
||||
|
||||
// Prefer store_date (actual release date) over cover_date (printed date)
|
||||
String dateToUse = issue.getStoreDate() != null ? issue.getStoreDate() : issue.getCoverDate();
|
||||
|
||||
// Use deck (brief summary) if description is very long or missing
|
||||
String description = issue.getDescription();
|
||||
if ((description == null || description.isEmpty()) && issue.getDeck() != null) {
|
||||
description = issue.getDeck();
|
||||
}
|
||||
|
||||
BookMetadata metadata = BookMetadata.builder()
|
||||
.provider(MetadataProvider.Comicvine)
|
||||
.comicvineId(comicvineId)
|
||||
.title(formattedTitle)
|
||||
.authors(authors)
|
||||
.tags(tags.isEmpty() ? null : tags)
|
||||
.thumbnailUrl(issue.getImage() != null ? issue.getImage().getMediumUrl() : null)
|
||||
.description(description)
|
||||
.seriesName(issue.getVolume() != null ? issue.getVolume().getName() : null)
|
||||
.seriesNumber(safeParseFloat(issue.getIssueNumber()))
|
||||
.seriesTotal(seriesTotal)
|
||||
.publisher(publisher)
|
||||
.publishedDate(safeParseDate(dateToUse))
|
||||
.externalUrl(issue.getSiteDetailUrl())
|
||||
.build();
|
||||
|
||||
if (metadata.getSeriesName() == null || metadata.getSeriesNumber() == null) {
|
||||
log.warn("Incomplete metadata for issue {}: missing series name or number", comicvineId);
|
||||
}
|
||||
|
||||
if (metadata.getAuthors().isEmpty()) {
|
||||
log.debug("No authors found for issue {} ({})", comicvineId, metadata.getTitle());
|
||||
}
|
||||
|
||||
return metadata;
|
||||
private BookMetadata convertToBookMetadata(ComicvineIssueResponse.IssueResults issue, int issueId, Comic volumeContext) {
|
||||
Comic comic = new Comic();
|
||||
comic.setId(issueId);
|
||||
comic.setIssueNumber(issue.getIssueNumber());
|
||||
comic.setVolume(issue.getVolume());
|
||||
comic.setName(issue.getName());
|
||||
comic.setPersonCredits(issue.getPersonCredits());
|
||||
comic.setCharacterCredits(issue.getCharacterCredits());
|
||||
comic.setTeamCredits(issue.getTeamCredits());
|
||||
comic.setStoryArcCredits(issue.getStoryArcCredits());
|
||||
comic.setLocationCredits(issue.getLocationCredits());
|
||||
comic.setImage(issue.getImage());
|
||||
comic.setDescription(issue.getDescription());
|
||||
comic.setDeck(issue.getDeck());
|
||||
comic.setStoreDate(issue.getStoreDate());
|
||||
comic.setCoverDate(issue.getCoverDate());
|
||||
comic.setSiteDetailUrl(issue.getSiteDetailUrl());
|
||||
return convertToBookMetadata(comic, volumeContext);
|
||||
}
|
||||
|
||||
private Set<String> extractTags(Comic comic) {
|
||||
Set<String> tags = new LinkedHashSet<>();
|
||||
|
||||
if (comic.getStoryArcCredits() != null) {
|
||||
comic.getStoryArcCredits().stream()
|
||||
.map(Comic.StoryArcCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(5) // Limit to top 5 story arcs
|
||||
.forEach(tags::add);
|
||||
private boolean hasComicDetails(ComicMetadata comic) {
|
||||
return hasNonEmptySet(comic.getCharacters())
|
||||
|| hasNonEmptySet(comic.getTeams())
|
||||
|| hasNonEmptySet(comic.getLocations())
|
||||
|| hasNonEmptySet(comic.getPencillers())
|
||||
|| hasNonEmptySet(comic.getInkers())
|
||||
|| hasNonEmptySet(comic.getColorists())
|
||||
|| hasNonEmptySet(comic.getLetterers())
|
||||
|| hasNonEmptySet(comic.getCoverArtists())
|
||||
|| hasNonEmptySet(comic.getEditors());
|
||||
}
|
||||
|
||||
private boolean hasNonEmptySet(Set<?> set) {
|
||||
return set != null && !set.isEmpty();
|
||||
}
|
||||
|
||||
private boolean hasAnyCredits(List<?>... creditLists) {
|
||||
for (List<?> list : creditLists) {
|
||||
if (list != null && !list.isEmpty()) return true;
|
||||
}
|
||||
|
||||
if (comic.getCharacterCredits() != null) {
|
||||
comic.getCharacterCredits().stream()
|
||||
return false;
|
||||
}
|
||||
|
||||
private ComicMetadata buildComicMetadata(
|
||||
String issueNumber,
|
||||
String volumeName,
|
||||
List<Comic.PersonCredit> personCredits,
|
||||
List<Comic.CharacterCredit> characterCredits,
|
||||
List<Comic.TeamCredit> teamCredits,
|
||||
List<Comic.StoryArcCredit> storyArcCredits,
|
||||
List<Comic.LocationCredit> locationCredits,
|
||||
String siteDetailUrl) {
|
||||
|
||||
ComicMetadata.ComicMetadataBuilder builder = ComicMetadata.builder();
|
||||
|
||||
builder.issueNumber(issueNumber);
|
||||
builder.volumeName(volumeName);
|
||||
|
||||
if (storyArcCredits != null && !storyArcCredits.isEmpty()) {
|
||||
builder.storyArc(storyArcCredits.getFirst().getName());
|
||||
}
|
||||
|
||||
if (personCredits != null && !personCredits.isEmpty()) {
|
||||
// "artist" in Comicvine means both pencils and inks — map to pencillers
|
||||
Set<String> pencillers = extractByRole(personCredits, "pencil");
|
||||
personCredits.stream()
|
||||
.filter(pc -> pc.getRole() != null)
|
||||
.filter(pc -> Arrays.stream(pc.getRole().toLowerCase().split(","))
|
||||
.map(String::trim)
|
||||
.anyMatch(r -> r.equals("artist")))
|
||||
.map(Comic.PersonCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.forEach(pencillers::add);
|
||||
builder.pencillers(pencillers);
|
||||
builder.inkers(extractByRole(personCredits, "ink"));
|
||||
builder.colorists(extractByRole(personCredits, "color"));
|
||||
builder.letterers(extractByRole(personCredits, "letter"));
|
||||
builder.coverArtists(extractByRole(personCredits, "cover"));
|
||||
builder.editors(extractByRole(personCredits, "editor"));
|
||||
}
|
||||
|
||||
if (characterCredits != null && !characterCredits.isEmpty()) {
|
||||
builder.characters(characterCredits.stream()
|
||||
.map(Comic.CharacterCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(10) // Limit to top 10 characters
|
||||
.forEach(tags::add);
|
||||
.filter(n -> n != null && !n.isEmpty())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
}
|
||||
|
||||
if (comic.getTeamCredits() != null) {
|
||||
comic.getTeamCredits().stream()
|
||||
|
||||
if (teamCredits != null && !teamCredits.isEmpty()) {
|
||||
builder.teams(teamCredits.stream()
|
||||
.map(Comic.TeamCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(5) // Limit to top 5 teams
|
||||
.forEach(tags::add);
|
||||
.filter(n -> n != null && !n.isEmpty())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
}
|
||||
|
||||
return tags;
|
||||
|
||||
if (locationCredits != null && !locationCredits.isEmpty()) {
|
||||
builder.locations(locationCredits.stream()
|
||||
.map(Comic.LocationCredit::getName)
|
||||
.filter(n -> n != null && !n.isEmpty())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
}
|
||||
|
||||
if (siteDetailUrl != null && !siteDetailUrl.isEmpty()) {
|
||||
builder.webLink(siteDetailUrl);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private Set<String> extractTagsFromIssue(ComicvineIssueResponse.IssueResults issue) {
|
||||
Set<String> tags = new LinkedHashSet<>();
|
||||
|
||||
if (issue.getStoryArcCredits() != null) {
|
||||
issue.getStoryArcCredits().stream()
|
||||
.map(Comic.StoryArcCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(5)
|
||||
.forEach(tags::add);
|
||||
}
|
||||
|
||||
if (issue.getCharacterCredits() != null) {
|
||||
issue.getCharacterCredits().stream()
|
||||
.map(Comic.CharacterCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(10)
|
||||
.forEach(tags::add);
|
||||
}
|
||||
|
||||
if (issue.getTeamCredits() != null) {
|
||||
issue.getTeamCredits().stream()
|
||||
.map(Comic.TeamCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.limit(5)
|
||||
.forEach(tags::add);
|
||||
}
|
||||
|
||||
return tags;
|
||||
private Set<String> extractByRole(List<Comic.PersonCredit> personCredits, String roleFragment) {
|
||||
return personCredits.stream()
|
||||
.filter(pc -> pc.getRole() != null && pc.getRole().toLowerCase().contains(roleFragment))
|
||||
.map(Comic.PersonCredit::getName)
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
|
||||
private String formatTitle(String seriesName, String issueNumber, String issueName) {
|
||||
if (seriesName == null) return issueName;
|
||||
|
||||
String normalizedIssue = normalizeIssueNumber(issueNumber);
|
||||
String title = seriesName + " #" + (normalizedIssue != null ? normalizedIssue : "");
|
||||
|
||||
// Append issue title if it exists and isn't just "Issue #X" or identical to series
|
||||
if (issueName != null && !issueName.isBlank()) {
|
||||
String lowerName = issueName.toLowerCase();
|
||||
boolean isGeneric = ISSUE_NUMBER_PATTERN.matcher(lowerName).matches() || lowerName.equals(seriesName.toLowerCase());
|
||||
@@ -775,10 +766,8 @@ public class ComicvineBookParser implements BookParser {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
// Primary writer roles
|
||||
Set<String> writerRoles = Set.of("writer", "script", "story", "plotter", "plot");
|
||||
|
||||
// Collect writers
|
||||
|
||||
Set<String> authors = personCredits.stream()
|
||||
.filter(pc -> {
|
||||
if (pc.getRole() == null) return false;
|
||||
@@ -789,9 +778,7 @@ public class ComicvineBookParser implements BookParser {
|
||||
.filter(name -> name != null && !name.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Fallback: If no writers found, check for "creator" role
|
||||
if (authors.isEmpty()) {
|
||||
// DEBUG: Log all roles if we missed them
|
||||
List<String> allRoles = personCredits.stream()
|
||||
.map(pc -> pc.getName() + " (" + pc.getRole() + ")")
|
||||
.toList();
|
||||
@@ -811,9 +798,7 @@ public class ComicvineBookParser implements BookParser {
|
||||
if (request.getTitle() != null && !request.getTitle().isEmpty()) {
|
||||
return request.getTitle();
|
||||
} else if (book.getPrimaryFile() != null && book.getPrimaryFile().getFileName() != null && !book.getPrimaryFile().getFileName().isEmpty()) {
|
||||
// Use filename but preserve parentheses for year extraction
|
||||
String name = book.getPrimaryFile().getFileName();
|
||||
// Remove extension only
|
||||
int dotIndex = name.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
name = name.substring(0, dotIndex);
|
||||
@@ -824,7 +809,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
}
|
||||
|
||||
private SeriesAndIssue extractSeriesAndIssue(String term) {
|
||||
// 1. Extract Year
|
||||
Integer year = null;
|
||||
Matcher yearMatcher = YEAR_PATTERN.matcher(term);
|
||||
String yearString = null;
|
||||
@@ -838,7 +822,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
} catch (NumberFormatException ignored) {}
|
||||
}
|
||||
|
||||
// 2. Remove ONLY the found year and noise from the term
|
||||
String cleaned = term;
|
||||
if (yearString != null) {
|
||||
cleaned = cleaned.replace(yearString, "");
|
||||
@@ -851,28 +834,23 @@ public class ComicvineBookParser implements BookParser {
|
||||
|
||||
log.debug("Cleaned filename: '{}'", cleaned);
|
||||
|
||||
// 3. Check for special issue keywords BEFORE extracting issue number
|
||||
String lowerCleaned = cleaned.toLowerCase();
|
||||
if (lowerCleaned.contains("annual") ||
|
||||
lowerCleaned.contains("special") ||
|
||||
lowerCleaned.contains("one-shot") ||
|
||||
lowerCleaned.contains("one shot")) {
|
||||
|
||||
// Try to extract number from "Annual 2" or "Special 3"
|
||||
Matcher specialMatcher = SPECIAL_ISSUE_PATTERN.matcher(cleaned);
|
||||
if (specialMatcher.find()) {
|
||||
String type = specialMatcher.group(1);
|
||||
String num = specialMatcher.group(2);
|
||||
// Return series name before the special keyword
|
||||
String series = cleaned.substring(0, specialMatcher.start()).trim();
|
||||
return new SeriesAndIssue(series, num, year, type, null);
|
||||
} else {
|
||||
// "Batman Annual" without number - search for all annuals
|
||||
return new SeriesAndIssue(cleaned, null, year, "annual", null);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Standard issue extraction with IMPROVED PATTERN
|
||||
Matcher matcher = SERIES_ISSUE_PATTERN.matcher(cleaned);
|
||||
if (matcher.find()) {
|
||||
String series = matcher.group(1).trim();
|
||||
@@ -892,14 +870,6 @@ public class ComicvineBookParser implements BookParser {
|
||||
return new SeriesAndIssue(cleaned, null, year, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient comparison of issue numbers.
|
||||
* Handles edge cases like:
|
||||
* - "1" vs "01" vs "1.0" (should match)
|
||||
* - "1.5" vs "1.50" (should match)
|
||||
* - "1AU" vs "1AU" (exact match for non-numeric)
|
||||
* - null handling
|
||||
*/
|
||||
private boolean issueNumbersMatch(String requested, String returned) {
|
||||
if (requested == null || returned == null) {
|
||||
return requested == null && returned == null;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.booklore.service.metadata.parser;
|
||||
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
|
||||
public interface DetailedMetadataProvider {
|
||||
BookMetadata fetchDetailedMetadata(String providerItemId);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import java.util.regex.Pattern;
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class GoodReadsParser implements BookParser {
|
||||
public class GoodReadsParser implements BookParser, DetailedMetadataProvider {
|
||||
|
||||
private static final String BASE_SEARCH_URL = "https://www.goodreads.com/search?q=";
|
||||
private static final String BASE_BOOK_URL = "https://www.goodreads.com/book/show/";
|
||||
@@ -111,10 +111,9 @@ public class GoodReadsParser implements BookParser {
|
||||
}
|
||||
}
|
||||
|
||||
List<BookMetadata> previews = fetchMetadataPreviews(book, fetchMetadataRequest).stream()
|
||||
return fetchMetadataPreviews(book, fetchMetadataRequest).stream()
|
||||
.limit(COUNT_DETAILED_METADATA_TO_GET)
|
||||
.toList();
|
||||
return fetchMetadataUsingPreviews(previews);
|
||||
}
|
||||
|
||||
private List<BookMetadata> fetchMetadataUsingPreviews(List<BookMetadata> previews) {
|
||||
@@ -481,6 +480,8 @@ public class GoodReadsParser implements BookParser {
|
||||
.goodreadsId(String.valueOf(extractGoodReadsIdPreview(previewBook)))
|
||||
.title(extractTitlePreview(previewBook))
|
||||
.authors(authors)
|
||||
.provider(MetadataProvider.GoodReads)
|
||||
.thumbnailUrl(extractThumbnailPreview(previewBook))
|
||||
.build();
|
||||
metadataPreviews.add(previewMetadata);
|
||||
}
|
||||
@@ -547,6 +548,33 @@ public class GoodReadsParser implements BookParser {
|
||||
}
|
||||
}
|
||||
|
||||
private String extractThumbnailPreview(Element book) {
|
||||
try {
|
||||
Element img = book.selectFirst("img");
|
||||
if (img != null) {
|
||||
String src = img.attr("src");
|
||||
if (!src.isBlank()) {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Error extracting thumbnail: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BookMetadata fetchDetailedMetadata(String goodreadsId) {
|
||||
log.info("GoodReads: Fetching detailed metadata for ID: {}", goodreadsId);
|
||||
try {
|
||||
Document document = fetchDoc(BASE_BOOK_URL + goodreadsId);
|
||||
return parseBookDetails(document, goodreadsId);
|
||||
} catch (Exception e) {
|
||||
log.error("Error fetching detailed metadata for GoodReads ID: {}", goodreadsId, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Document fetchDoc(String url) {
|
||||
try {
|
||||
Connection.Response response = Jsoup.connect(url)
|
||||
|
||||
@@ -249,6 +249,9 @@ public class MetadataChangeDetector {
|
||||
if (hasComicMetadataChanges(newMeta, existingMeta)) {
|
||||
return true;
|
||||
}
|
||||
if (hasComicLockChanges(newMeta, existingMeta)) {
|
||||
return true;
|
||||
}
|
||||
return differsLock(newMeta.getCoverLocked(), existingMeta.getCoverLocked()) || differsLock(newMeta.getAudiobookCoverLocked(), existingMeta.getAudiobookCoverLocked());
|
||||
}
|
||||
|
||||
@@ -354,6 +357,14 @@ public class MetadataChangeDetector {
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean differsValue(Object newVal, Object oldVal) {
|
||||
Object normNew = normalize(newVal);
|
||||
Object normOld = normalize(oldVal);
|
||||
if (normOld == null && isEffectivelyEmpty(normNew)) return false;
|
||||
if (normNew == null && isEffectivelyEmpty(normOld)) return false;
|
||||
return !Objects.equals(normNew, normOld);
|
||||
}
|
||||
|
||||
private static Set<String> toNameSet(Set<?> entities) {
|
||||
if (entities == null) {
|
||||
return Collections.emptySet();
|
||||
@@ -378,32 +389,106 @@ public class MetadataChangeDetector {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Comic metadata in DTO but not in entity - this is a change
|
||||
// Comic metadata in DTO but not in entity - only a value change if DTO has actual data
|
||||
if (comicEntity == null) {
|
||||
return true;
|
||||
return hasNonEmptyComicValue(comicDto);
|
||||
}
|
||||
|
||||
// Compare individual fields
|
||||
return !Objects.equals(normalize(comicDto.getIssueNumber()), normalize(comicEntity.getIssueNumber()))
|
||||
|| !Objects.equals(normalize(comicDto.getVolumeName()), normalize(comicEntity.getVolumeName()))
|
||||
|| !Objects.equals(comicDto.getVolumeNumber(), comicEntity.getVolumeNumber())
|
||||
|| !Objects.equals(normalize(comicDto.getStoryArc()), normalize(comicEntity.getStoryArc()))
|
||||
|| !Objects.equals(comicDto.getStoryArcNumber(), comicEntity.getStoryArcNumber())
|
||||
|| !Objects.equals(normalize(comicDto.getAlternateSeries()), normalize(comicEntity.getAlternateSeries()))
|
||||
|| !Objects.equals(normalize(comicDto.getAlternateIssue()), normalize(comicEntity.getAlternateIssue()))
|
||||
|| !Objects.equals(normalize(comicDto.getImprint()), normalize(comicEntity.getImprint()))
|
||||
|| !Objects.equals(normalize(comicDto.getFormat()), normalize(comicEntity.getFormat()))
|
||||
|| !Objects.equals(comicDto.getBlackAndWhite(), comicEntity.getBlackAndWhite())
|
||||
|| !Objects.equals(comicDto.getManga(), comicEntity.getManga())
|
||||
|| !Objects.equals(normalize(comicDto.getReadingDirection()), normalize(comicEntity.getReadingDirection()))
|
||||
|| !Objects.equals(normalize(comicDto.getWebLink()), normalize(comicEntity.getWebLink()))
|
||||
|| !Objects.equals(normalize(comicDto.getNotes()), normalize(comicEntity.getNotes()))
|
||||
// Compare individual fields (using differsValue to treat null and empty as equivalent)
|
||||
return differsValue(comicDto.getIssueNumber(), comicEntity.getIssueNumber())
|
||||
|| differsValue(comicDto.getVolumeName(), comicEntity.getVolumeName())
|
||||
|| differsValue(comicDto.getVolumeNumber(), comicEntity.getVolumeNumber())
|
||||
|| differsValue(comicDto.getStoryArc(), comicEntity.getStoryArc())
|
||||
|| differsValue(comicDto.getStoryArcNumber(), comicEntity.getStoryArcNumber())
|
||||
|| differsValue(comicDto.getAlternateSeries(), comicEntity.getAlternateSeries())
|
||||
|| differsValue(comicDto.getAlternateIssue(), comicEntity.getAlternateIssue())
|
||||
|| differsValue(comicDto.getImprint(), comicEntity.getImprint())
|
||||
|| differsValue(comicDto.getFormat(), comicEntity.getFormat())
|
||||
|| differsValue(comicDto.getBlackAndWhite(), comicEntity.getBlackAndWhite())
|
||||
|| differsValue(comicDto.getManga(), comicEntity.getManga())
|
||||
|| differsValue(comicDto.getReadingDirection(), comicEntity.getReadingDirection())
|
||||
|| differsValue(comicDto.getWebLink(), comicEntity.getWebLink())
|
||||
|| differsValue(comicDto.getNotes(), comicEntity.getNotes())
|
||||
|| !stringSetsEqual(comicDto.getCharacters(), extractCharacterNames(comicEntity.getCharacters()))
|
||||
|| !stringSetsEqual(comicDto.getTeams(), extractTeamNames(comicEntity.getTeams()))
|
||||
|| !stringSetsEqual(comicDto.getLocations(), extractLocationNames(comicEntity.getLocations()))
|
||||
|| hasCreatorChanges(comicDto, comicEntity);
|
||||
}
|
||||
|
||||
private static boolean hasComicLockChanges(BookMetadata newMeta, BookMetadataEntity existingMeta) {
|
||||
ComicMetadata comicDto = newMeta.getComicMetadata();
|
||||
ComicMetadataEntity comicEntity = existingMeta.getComicMetadata();
|
||||
if (comicDto == null) return false;
|
||||
if (comicEntity == null) return false;
|
||||
return differsLock(comicDto.getIssueNumberLocked(), comicEntity.getIssueNumberLocked())
|
||||
|| differsLock(comicDto.getVolumeNameLocked(), comicEntity.getVolumeNameLocked())
|
||||
|| differsLock(comicDto.getVolumeNumberLocked(), comicEntity.getVolumeNumberLocked())
|
||||
|| differsLock(comicDto.getStoryArcLocked(), comicEntity.getStoryArcLocked())
|
||||
|| differsLock(comicDto.getStoryArcNumberLocked(), comicEntity.getStoryArcNumberLocked())
|
||||
|| differsLock(comicDto.getAlternateSeriesLocked(), comicEntity.getAlternateSeriesLocked())
|
||||
|| differsLock(comicDto.getAlternateIssueLocked(), comicEntity.getAlternateIssueLocked())
|
||||
|| differsLock(comicDto.getImprintLocked(), comicEntity.getImprintLocked())
|
||||
|| differsLock(comicDto.getFormatLocked(), comicEntity.getFormatLocked())
|
||||
|| differsLock(comicDto.getBlackAndWhiteLocked(), comicEntity.getBlackAndWhiteLocked())
|
||||
|| differsLock(comicDto.getMangaLocked(), comicEntity.getMangaLocked())
|
||||
|| differsLock(comicDto.getReadingDirectionLocked(), comicEntity.getReadingDirectionLocked())
|
||||
|| differsLock(comicDto.getWebLinkLocked(), comicEntity.getWebLinkLocked())
|
||||
|| differsLock(comicDto.getNotesLocked(), comicEntity.getNotesLocked())
|
||||
|| differsLock(comicDto.getCreatorsLocked(), comicEntity.getCreatorsLocked())
|
||||
|| differsLock(comicDto.getPencillersLocked(), comicEntity.getPencillersLocked())
|
||||
|| differsLock(comicDto.getInkersLocked(), comicEntity.getInkersLocked())
|
||||
|| differsLock(comicDto.getColoristsLocked(), comicEntity.getColoristsLocked())
|
||||
|| differsLock(comicDto.getLetterersLocked(), comicEntity.getLetterersLocked())
|
||||
|| differsLock(comicDto.getCoverArtistsLocked(), comicEntity.getCoverArtistsLocked())
|
||||
|| differsLock(comicDto.getEditorsLocked(), comicEntity.getEditorsLocked())
|
||||
|| differsLock(comicDto.getCharactersLocked(), comicEntity.getCharactersLocked())
|
||||
|| differsLock(comicDto.getTeamsLocked(), comicEntity.getTeamsLocked())
|
||||
|| differsLock(comicDto.getLocationsLocked(), comicEntity.getLocationsLocked());
|
||||
}
|
||||
|
||||
public static boolean hasLockChanges(BookMetadata newMeta, BookMetadataEntity existingMeta) {
|
||||
for (FieldDescriptor<?> field : SIMPLE_FIELDS) {
|
||||
if (differsLock(field.getNewLock(newMeta), field.getOldLock(existingMeta))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (CollectionFieldDescriptor field : COLLECTION_FIELDS) {
|
||||
if (differsLock(field.getNewLock(newMeta), field.getOldLock(existingMeta))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (differsLock(newMeta.getCoverLocked(), existingMeta.getCoverLocked())) return true;
|
||||
if (differsLock(newMeta.getAudiobookCoverLocked(), existingMeta.getAudiobookCoverLocked())) return true;
|
||||
if (differsLock(newMeta.getReviewsLocked(), existingMeta.getReviewsLocked())) return true;
|
||||
return hasComicLockChanges(newMeta, existingMeta);
|
||||
}
|
||||
|
||||
private static boolean hasNonEmptyComicValue(ComicMetadata dto) {
|
||||
return !isEffectivelyEmpty(dto.getIssueNumber())
|
||||
|| !isEffectivelyEmpty(dto.getVolumeName())
|
||||
|| dto.getVolumeNumber() != null
|
||||
|| !isEffectivelyEmpty(dto.getStoryArc())
|
||||
|| dto.getStoryArcNumber() != null
|
||||
|| !isEffectivelyEmpty(dto.getAlternateSeries())
|
||||
|| !isEffectivelyEmpty(dto.getAlternateIssue())
|
||||
|| !isEffectivelyEmpty(dto.getImprint())
|
||||
|| !isEffectivelyEmpty(dto.getFormat())
|
||||
|| dto.getBlackAndWhite() != null
|
||||
|| dto.getManga() != null
|
||||
|| !isEffectivelyEmpty(dto.getReadingDirection())
|
||||
|| !isEffectivelyEmpty(dto.getWebLink())
|
||||
|| !isEffectivelyEmpty(dto.getNotes())
|
||||
|| (dto.getCharacters() != null && !dto.getCharacters().isEmpty())
|
||||
|| (dto.getTeams() != null && !dto.getTeams().isEmpty())
|
||||
|| (dto.getLocations() != null && !dto.getLocations().isEmpty())
|
||||
|| (dto.getPencillers() != null && !dto.getPencillers().isEmpty())
|
||||
|| (dto.getInkers() != null && !dto.getInkers().isEmpty())
|
||||
|| (dto.getColorists() != null && !dto.getColorists().isEmpty())
|
||||
|| (dto.getLetterers() != null && !dto.getLetterers().isEmpty())
|
||||
|| (dto.getCoverArtists() != null && !dto.getCoverArtists().isEmpty())
|
||||
|| (dto.getEditors() != null && !dto.getEditors().isEmpty());
|
||||
}
|
||||
|
||||
private static boolean stringSetsEqual(Set<String> set1, Set<String> set2) {
|
||||
if (set1 == null && (set2 == null || set2.isEmpty())) return true;
|
||||
if (set1 == null || set2 == null) return false;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Add per-field lock columns to comic_metadata (previously these shared grouped locks)
|
||||
|
||||
-- Fields previously grouped under issue_number_locked
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS imprint_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS format_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS black_and_white_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS manga_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS reading_direction_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS web_link_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS notes_locked BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Fields previously grouped under story_arc_locked
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS story_arc_number_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS alternate_series_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS alternate_issue_locked BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Fields previously grouped under creators_locked (per-role locks)
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS pencillers_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS inkers_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS colorists_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS letterers_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS cover_artists_locked BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE comic_metadata ADD COLUMN IF NOT EXISTS editors_locked BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Initialize new columns from their previous group lock values for existing rows
|
||||
UPDATE comic_metadata SET imprint_locked = COALESCE(issue_number_locked, FALSE) WHERE imprint_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET format_locked = COALESCE(issue_number_locked, FALSE) WHERE format_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET black_and_white_locked = COALESCE(issue_number_locked, FALSE) WHERE black_and_white_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET manga_locked = COALESCE(issue_number_locked, FALSE) WHERE manga_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET reading_direction_locked = COALESCE(issue_number_locked, FALSE) WHERE reading_direction_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET web_link_locked = COALESCE(issue_number_locked, FALSE) WHERE web_link_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET notes_locked = COALESCE(issue_number_locked, FALSE) WHERE notes_locked = FALSE AND COALESCE(issue_number_locked, FALSE) = TRUE;
|
||||
|
||||
UPDATE comic_metadata SET story_arc_number_locked = COALESCE(story_arc_locked, FALSE) WHERE story_arc_number_locked = FALSE AND COALESCE(story_arc_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET alternate_series_locked = COALESCE(story_arc_locked, FALSE) WHERE alternate_series_locked = FALSE AND COALESCE(story_arc_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET alternate_issue_locked = COALESCE(story_arc_locked, FALSE) WHERE alternate_issue_locked = FALSE AND COALESCE(story_arc_locked, FALSE) = TRUE;
|
||||
|
||||
UPDATE comic_metadata SET pencillers_locked = COALESCE(creators_locked, FALSE) WHERE pencillers_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET inkers_locked = COALESCE(creators_locked, FALSE) WHERE inkers_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET colorists_locked = COALESCE(creators_locked, FALSE) WHERE colorists_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET letterers_locked = COALESCE(creators_locked, FALSE) WHERE letterers_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET cover_artists_locked = COALESCE(creators_locked, FALSE) WHERE cover_artists_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
UPDATE comic_metadata SET editors_locked = COALESCE(creators_locked, FALSE) WHERE editors_locked = FALSE AND COALESCE(creators_locked, FALSE) = TRUE;
|
||||
@@ -21,7 +21,6 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
@@ -318,7 +317,7 @@ class BookServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBookContent_returnsByteArrayResource() throws Exception {
|
||||
void getBookContent_returnsResource() throws Exception {
|
||||
BookEntity entity = new BookEntity();
|
||||
entity.setId(10L);
|
||||
when(bookRepository.findById(10L)).thenReturn(Optional.of(entity));
|
||||
@@ -326,9 +325,9 @@ class BookServiceTest {
|
||||
Files.write(path, "hello".getBytes());
|
||||
try (MockedStatic<FileUtils> fileUtilsMock = mockStatic(FileUtils.class)) {
|
||||
fileUtilsMock.when(() -> FileUtils.getBookFullPath(entity)).thenReturn(path.toString());
|
||||
ResponseEntity<ByteArrayResource> response = bookService.getBookContent(10L);
|
||||
ResponseEntity<Resource> response = bookService.getBookContent(10L);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertArrayEquals("hello".getBytes(), response.getBody().getByteArray());
|
||||
assertArrayEquals("hello".getBytes(), response.getBody().getInputStream().readAllBytes());
|
||||
} finally {
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
@@ -341,13 +340,13 @@ class BookServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBookContent_fileIoError_throwsIOException() throws Exception {
|
||||
void getBookContent_fileNotFound_throwsException() {
|
||||
BookEntity entity = new BookEntity();
|
||||
entity.setId(12L);
|
||||
when(bookRepository.findById(12L)).thenReturn(Optional.of(entity));
|
||||
try (MockedStatic<FileUtils> fileUtilsMock = mockStatic(FileUtils.class)) {
|
||||
fileUtilsMock.when(() -> FileUtils.getBookFullPath(entity)).thenReturn("/tmp/nonexistentfile.txt");
|
||||
assertThrows(java.io.FileNotFoundException.class, () -> bookService.getBookContent(12L));
|
||||
assertThrows(APIException.class, () -> bookService.getBookContent(12L));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user