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:
ACX
2026-02-09 16:43:53 -07:00
committed by GitHub
parent bc7ba8b933
commit ffd4615b87
79 changed files with 2548 additions and 1051 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,6 +77,7 @@ public class BookLoreUser {
public boolean koReaderEnabled;
public boolean enableSeriesView;
public boolean autoSaveMetadata;
public List<String> visibleFilters;
public DashboardConfig dashboardConfig;
@Data

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
package org.booklore.service.metadata.parser;
import org.booklore.model.dto.BookMetadata;
public interface DetailedMetadataProvider {
BookMetadata fetchDetailedMetadata(String providerItemId);
}

View File

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

View File

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

View File

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

View File

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