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

@@ -18,26 +18,33 @@
## 🧪 Testing
<!-- What did you test? What edge cases did you cover? What could still break? -->
<!-- MANDATORY. PRs without this filled out will be closed.
- What exact steps did you follow to verify the fix or feature works?
- How did you manually regression test existing functionality?
- What edge cases did you cover?
-->
## 📸 Screenshots _(if applicable)_
## 📸 Screenshots / Video (MANDATORY)
<!-- Attach screenshots or videos for UI changes -->
> Due to an increase in untested, AI-generated PRs, we have made this section mandatory for all submissions. Please include screenshots or a screen recording that demonstrates your change working end-to-end (e.g., the bug fix in action, the new feature working, API responses, or test output for backend changes). **PRs that do not include this will be closed without review.** Thank you for helping us keep the project stable.
<!-- Attach screenshots or screen recordings here -->
---
## ✅ Pre-Submission Checklist
> **All boxes must be checked before requesting review.** Incomplete PRs will be closed without review, no exceptions.
> **All boxes must be checked before requesting review.** Incomplete PRs will be closed without review. No exceptions.
- [ ] Code follows project style guidelines and conventions
- [ ] Branch is up to date with `develop` (merge conflicts resolved)
- [ ] Automated tests added or updated to cover changes (backend **and** frontend)
- [ ] All tests pass locally (`./gradlew test` for backend, `ng test` for frontend)
- [ ] Changes manually verified in local dev environment (including related features)
- [ ] Screenshots or video proving the change works are attached above **(MANDATORY)**
- [ ] Flyway migration versioning is correct _(if schema was modified)_
- [ ] Documentation PR submitted to [booklore-docs](https://github.com/booklore-app/booklore-docs) _(if user-facing changes)_
- [ ] PR is reasonably scoped (PRs over 500+ changed lines will be closed, split into smaller PRs)
- [ ] PR is reasonably scoped (PRs over 1000+ changed lines will be closed, split into smaller PRs)
### 🤖 AI-Assisted Contributions

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)) {
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)
.body(new ByteArrayResource(inputStream.readAllBytes()));
}
.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 {
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.setBookCoverHash(BookCoverUtils.generateCoverHash());
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
}
bookEntity.setBookCoverHash(BookCoverUtils.generateCoverHash());
} 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.");
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();
}
}
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;
}
BookMetadata metadata = getBookMetadata(amazonBookId);
if (metadata == null) {
log.debug("Skipping null metadata for ID: {}", amazonBookId);
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;
}
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;
previews.add(BookMetadata.builder()
.asin(asin)
.title(titleText)
.thumbnailUrl(thumbnailUrl)
.provider(MetadataProvider.Amazon)
.build());
}
fetchedBookMetadata.add(metadata);
return previews;
}
return fetchedBookMetadata;
@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<>());
String queryUrl = buildQueryUrl(fetchMetadataRequest, book);
if (queryUrl == null) {
log.error("Query URL is null, cannot proceed with Audible search.");
return Collections.emptyList();
}
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();
}
}
if (audibleIds.isEmpty()) {
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;
}
List<BookMetadata> fetchedMetadata = new ArrayList<>();
for (String audibleId : audibleIds) {
if (audibleId == null || audibleId.isBlank()) {
log.debug("Skipping null or blank Audible ID.");
continue;
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;
}
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;
Element img = titleLink.selectFirst("img");
if (img == null && titleLink.parent() != null) {
img = titleLink.parent().selectFirst("img");
}
fetchedMetadata.add(metadata);
if (img != null) {
String src = img.attr("src");
if (!src.isBlank()) return src;
}
return fetchedMetadata;
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());
@@ -121,20 +126,17 @@ public class ComicvineBookParser implements BookParser {
}
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,10 +568,9 @@ 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();
@@ -588,26 +578,31 @@ public class ComicvineBookParser implements BookParser {
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());
}
@@ -633,124 +628,121 @@ 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);
}
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);
}
// 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();
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());
}
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();
private boolean hasNonEmptySet(Set<?> set) {
return set != null && !set.isEmpty();
}
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);
private boolean hasAnyCredits(List<?>... creditLists) {
for (List<?> list : creditLists) {
if (list != null && !list.isEmpty()) return true;
}
return false;
}
if (metadata.getAuthors().isEmpty()) {
log.debug("No authors found for issue {} ({})", comicvineId, metadata.getTitle());
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());
}
return metadata;
}
private Set<String> extractTags(Comic comic) {
Set<String> tags = new LinkedHashSet<>();
if (comic.getStoryArcCredits() != null) {
comic.getStoryArcCredits().stream()
.map(Comic.StoryArcCredit::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())
.limit(5) // Limit to top 5 story arcs
.forEach(tags::add);
.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 (comic.getCharacterCredits() != null) {
comic.getCharacterCredits().stream()
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(n -> n != null && !n.isEmpty())
.collect(Collectors.toCollection(LinkedHashSet::new)));
}
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> 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())
.limit(5) // Limit to top 5 teams
.forEach(tags::add);
}
return tags;
}
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;
.collect(Collectors.toCollection(LinkedHashSet::new));
}
private String formatTitle(String seriesName, String issueNumber, String issueName) {
@@ -759,7 +751,6 @@ public class ComicvineBookParser implements BookParser {
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));
}
}

View File

@@ -260,7 +260,7 @@
[(ngModel)]="editingLibraryIds"
placeholder="Select libraries new users can access"
appendTo="body"
styleClass="libraries-select">
class="libraries-select">
</p-multiSelect>
</div>
</div>

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="closeDialog()"
styleClass="close-button">
class="close-button">
</p-button>
</div>
@@ -25,7 +25,7 @@
optionLabel="label"
optionValue="value"
placeholder="Select file type"
styleClass="full-width"
class="full-width"
[disabled]="isUploading"
></p-select>
@@ -36,7 +36,7 @@
[(ngModel)]="description"
rows="3"
placeholder="Add a description for this file..."
styleClass="full-width"
class="full-width"
[disabled]="isUploading"
></p-textarea>
</div>

View File

@@ -9,7 +9,7 @@
optionValue="value"
class="filter-mode-select"
allowEmpty="false"
styleClass="h-8"></p-selectButton>
class="h-8"></p-selectButton>
</div>
<div class="filter-content">

View File

@@ -20,7 +20,8 @@ export type FilterType =
| 'personalRating' | 'publisher' | 'matchScore' | 'library' | 'shelf'
| 'shelfStatus' | 'tag' | 'publishedDate' | 'fileSize' | 'amazonRating'
| 'goodreadsRating' | 'hardcoverRating' | 'language' | 'pageCount' | 'mood'
| 'ageRating' | 'contentRating';
| 'ageRating' | 'contentRating'
| 'comicCharacter' | 'comicTeam' | 'comicLocation' | 'comicCreator';
export type SortMode = 'count' | 'sortIndex';
@@ -156,7 +157,11 @@ export const FILTER_LABELS: Readonly<Record<FilterType, string>> = {
pageCount: 'Page Count',
mood: 'Mood',
ageRating: 'Age Rating',
contentRating: 'Content Rating'
contentRating: 'Content Rating',
comicCharacter: 'Comic Character',
comicTeam: 'Comic Team',
comicLocation: 'Comic Location',
comicCreator: 'Comic Creator'
};
// ============================================================================
@@ -230,6 +235,34 @@ export const FILTER_EXTRACTORS: Readonly<Record<Exclude<FilterType, 'library'>,
if (!rating) return [];
const label = CONTENT_RATING_LABELS[rating] ?? rating;
return [{id: rating, name: label}];
},
comicCharacter: (book) => extractStringsAsFilters(book.metadata?.comicMetadata?.characters),
comicTeam: (book) => extractStringsAsFilters(book.metadata?.comicMetadata?.teams),
comicLocation: (book) => extractStringsAsFilters(book.metadata?.comicMetadata?.locations),
comicCreator: (book) => {
const comic = book.metadata?.comicMetadata;
if (!comic) return [];
const creators: FilterValue[] = [];
const roleLabels: Record<string, string> = {
penciller: 'Penciller', inker: 'Inker', colorist: 'Colorist',
letterer: 'Letterer', coverArtist: 'Cover Artist', editor: 'Editor'
};
const roles: [string[] | undefined, string][] = [
[comic.pencillers, 'penciller'],
[comic.inkers, 'inker'],
[comic.colorists, 'colorist'],
[comic.letterers, 'letterer'],
[comic.coverArtists, 'coverArtist'],
[comic.editors, 'editor']
];
for (const [names, role] of roles) {
if (names) {
for (const name of names) {
creators.push({id: `${name}:${role}`, name: `${name} (${roleLabels[role]})`});
}
}
}
return creators;
}
};
@@ -254,5 +287,9 @@ export const FILTER_CONFIGS: Readonly<Record<Exclude<FilterType, 'library'>, Omi
pageCount: {label: 'Page Count', sortMode: 'sortIndex', isNumericId: true},
mood: {label: 'Mood', sortMode: 'count'},
ageRating: {label: 'Age Rating', sortMode: 'sortIndex', isNumericId: true},
contentRating: {label: 'Content Rating', sortMode: 'count'}
contentRating: {label: 'Content Rating', sortMode: 'count'},
comicCharacter: {label: 'Comic Character', sortMode: 'count'},
comicTeam: {label: 'Comic Team', sortMode: 'count'},
comicLocation: {label: 'Comic Location', sortMode: 'count'},
comicCreator: {label: 'Comic Creator', sortMode: 'count'}
};

View File

@@ -10,7 +10,7 @@ import {BookRuleEvaluatorService} from '../../../../magic-shelf/service/book-rul
import {GroupRule} from '../../../../magic-shelf/component/magic-shelf-component';
import {EntityType} from '../book-browser.component';
import {Filter, FILTER_CONFIGS, FILTER_EXTRACTORS, FilterType, FilterValue, NUMERIC_ID_FILTER_TYPES, SortMode} from './book-filter.config';
import {filterBooksByFilters} from '../filters/SidebarFilter';
import {filterBooksByFilters} from '../filters/sidebar-filter';
import {BookFilterMode} from '../../../../settings/user-management/user.service';
const MAX_FILTER_ITEMS = 100;

View File

@@ -1,169 +0,0 @@
import {combineLatest, Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {BookFilter} from './BookFilter';
import {BookState} from '../../../model/state/book-state.model';
import {fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges} from '../book-filter/book-filter.config';
import {Book, ReadStatus} from '../../../model/book.model';
import {BookFilterMode} from '../../../../settings/user-management/user.service';
export function isRatingInRange(rating: number | undefined | null, rangeId: string | number): boolean {
if (rating == null) return false;
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
const range = ratingRanges.find(r => r.id === numericId);
if (!range) return false;
return rating >= range.min && rating < range.max;
}
export function isRatingInRange10(rating: number | undefined | null, rangeId: string | number): boolean {
if (rating == null) return false;
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
return Math.round(rating) === numericId;
}
export function isFileSizeInRange(fileSizeKb: number | undefined, rangeId: string | number): boolean {
if (fileSizeKb == null) return false;
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
const range = fileSizeRanges.find(r => r.id === numericId);
if (!range) return false;
return fileSizeKb >= range.min && fileSizeKb < range.max;
}
export function isPageCountInRange(pageCount: number | undefined, rangeId: string | number): boolean {
if (pageCount == null) return false;
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
const range = pageCountRanges.find(r => r.id === numericId);
if (!range) return false;
return pageCount >= range.min && pageCount < range.max;
}
export function isMatchScoreInRange(score: number | undefined | null, rangeId: string | number): boolean {
if (score == null) return false;
const normalizedScore = score > 1 ? score / 100 : score;
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
const range = matchScoreRanges.find(r => r.id === numericId);
if (!range) return false;
return normalizedScore >= range.min && normalizedScore < range.max;
}
export function doesBookMatchReadStatus(book: Book, selected: unknown[]): boolean {
const status = book.readStatus ?? ReadStatus.UNSET;
return selected.includes(status);
}
export function doesBookMatchFilter(
book: Book,
filterType: string,
filterValues: unknown[],
mode: BookFilterMode
): boolean {
if (!Array.isArray(filterValues) || filterValues.length === 0) {
return mode === 'or';
}
switch (filterType) {
case 'author':
return mode === 'or'
? filterValues.some(val => book.metadata?.authors?.includes(val as string))
: filterValues.every(val => book.metadata?.authors?.includes(val as string));
case 'category':
return mode === 'or'
? filterValues.some(val => book.metadata?.categories?.includes(val as string))
: filterValues.every(val => book.metadata?.categories?.includes(val as string));
case 'series':
return mode === 'or'
? filterValues.some(val => book.metadata?.seriesName === val)
: filterValues.every(val => book.metadata?.seriesName === val);
case 'bookType':
return filterValues.includes(book.primaryFile?.bookType);
case 'readStatus':
return doesBookMatchReadStatus(book, filterValues);
case 'personalRating':
return filterValues.some(range => isRatingInRange10(book.personalRating, range as string | number));
case 'publisher':
return mode === 'or'
? filterValues.some(val => book.metadata?.publisher === val)
: filterValues.every(val => book.metadata?.publisher === val);
case 'matchScore':
return filterValues.some(range => isMatchScoreInRange(book.metadataMatchScore, range as string | number));
case 'library':
return mode === 'or'
? filterValues.some(val => val == book.libraryId)
: filterValues.every(val => val == book.libraryId);
case 'shelf':
return mode === 'or'
? filterValues.some(val => book.shelves?.some(s => s.id == val))
: filterValues.every(val => book.shelves?.some(s => s.id == val));
case 'shelfStatus':
const shelved = book.shelves && book.shelves.length > 0 ? 'shelved' : 'unshelved';
return filterValues.includes(shelved);
case 'tag':
return mode === 'or'
? filterValues.some(val => book.metadata?.tags?.includes(val as string))
: filterValues.every(val => book.metadata?.tags?.includes(val as string));
case 'publishedDate':
const bookYear = book.metadata?.publishedDate
? new Date(book.metadata.publishedDate).getFullYear()
: null;
return bookYear ? filterValues.some(val => val == bookYear || val == bookYear.toString()) : false;
case 'fileSize':
return filterValues.some(range => isFileSizeInRange(book.fileSizeKb, range as string | number));
case 'amazonRating':
return filterValues.some(range => isRatingInRange(book.metadata?.amazonRating, range as string | number));
case 'goodreadsRating':
return filterValues.some(range => isRatingInRange(book.metadata?.goodreadsRating, range as string | number));
case 'hardcoverRating':
return filterValues.some(range => isRatingInRange(book.metadata?.hardcoverRating, range as string | number));
case 'language':
return filterValues.includes(book.metadata?.language);
case 'pageCount':
return filterValues.some(range => isPageCountInRange(book.metadata?.pageCount!, range as string | number));
case 'mood':
return mode === 'or'
? filterValues.some(val => book.metadata?.moods?.includes(val as string))
: filterValues.every(val => book.metadata?.moods?.includes(val as string));
default:
return false;
}
}
export function filterBooksByFilters(
books: Book[],
activeFilters: Record<string, unknown[]> | null,
mode: BookFilterMode,
excludeFilterType?: string
): Book[] {
if (!activeFilters) return books;
const filterEntries = Object.entries(activeFilters)
.filter(([type]) => type !== excludeFilterType);
if (filterEntries.length === 0) return books;
return books.filter(book => {
const matches = filterEntries.map(([filterType, filterValues]) =>
doesBookMatchFilter(book, filterType, filterValues, mode)
);
return mode === 'or' ? matches.some(m => m) : matches.every(m => m);
});
}
export class SideBarFilter implements BookFilter {
constructor(private selectedFilter$: Observable<unknown>, private selectedFilterMode$: Observable<BookFilterMode>) {
}
filter(bookState: BookState): Observable<BookState> {
return combineLatest([this.selectedFilter$, this.selectedFilterMode$]).pipe(
map(([activeFilters, mode]) => {
if (bookState.books == null) return bookState;
if (!activeFilters) return bookState;
const filteredBooks = filterBooksByFilters(
bookState.books || [],
activeFilters as Record<string, unknown[]>,
mode
);
return {...bookState, books: filteredBooks};
})
);
}
}

View File

@@ -2,7 +2,7 @@ import {combineLatest, Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {BookFilter} from './BookFilter';
import {BookState} from '../../../model/state/book-state.model';
import {AGE_RATING_OPTIONS, fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges, RangeConfig} from '../book-filter/book-filter.config';
import {fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges} from '../book-filter/book-filter.config';
import {Book, ReadStatus} from '../../../model/book.model';
import {BookFilterMode} from '../../../../settings/user-management/user.service';
@@ -45,41 +45,30 @@ export function isMatchScoreInRange(score: number | undefined | null, rangeId: s
return normalizedScore >= range.min && normalizedScore < range.max;
}
export function doesBookMatchReadStatus(book: Book, selected: string[]): boolean {
export function doesBookMatchReadStatus(book: Book, selected: unknown[]): boolean {
const status = book.readStatus ?? ReadStatus.UNSET;
return selected.includes(status);
}
export function isAgeRatingMatch(ageRating: number | undefined | null, rangeId: string | number): boolean {
if (ageRating == null) return false;
const numericId = typeof rangeId === 'string' ? Number(rangeId) : rangeId;
return ageRating === numericId;
}
export class SideBarFilter implements BookFilter {
constructor(private selectedFilter$: Observable<unknown>, private selectedFilterMode$: Observable<BookFilterMode>) {
}
filter(bookState: BookState): Observable<BookState> {
return combineLatest([this.selectedFilter$, this.selectedFilterMode$]).pipe(
map(([activeFilters, mode]) => {
if (bookState.books == null) return bookState;
if (!activeFilters) return bookState;
const filteredBooks = (bookState.books || []).filter(book => {
const matches = Object.entries(activeFilters).map(([filterType, filterValues]) => {
export function doesBookMatchFilter(
book: Book,
filterType: string,
filterValues: unknown[],
mode: BookFilterMode
): boolean {
if (!Array.isArray(filterValues) || filterValues.length === 0) {
return mode === 'or';
}
switch (filterType) {
case 'author':
return mode === 'or'
? filterValues.some(val => book.metadata?.authors?.includes(val))
: filterValues.every(val => book.metadata?.authors?.includes(val));
? filterValues.some(val => book.metadata?.authors?.includes(val as string))
: filterValues.every(val => book.metadata?.authors?.includes(val as string));
case 'category':
return mode === 'or'
? filterValues.some(val => book.metadata?.categories?.includes(val))
: filterValues.every(val => book.metadata?.categories?.includes(val));
? filterValues.some(val => book.metadata?.categories?.includes(val as string))
: filterValues.every(val => book.metadata?.categories?.includes(val as string));
case 'series':
return mode === 'or'
? filterValues.some(val => book.metadata?.seriesName === val)
@@ -89,13 +78,13 @@ export class SideBarFilter implements BookFilter {
case 'readStatus':
return doesBookMatchReadStatus(book, filterValues);
case 'personalRating':
return filterValues.some(range => isRatingInRange10(book.personalRating, range));
return filterValues.some(range => isRatingInRange10(book.personalRating, range as string | number));
case 'publisher':
return mode === 'or'
? filterValues.some(val => book.metadata?.publisher === val)
: filterValues.every(val => book.metadata?.publisher === val);
case 'matchScore':
return filterValues.some(range => isMatchScoreInRange(book.metadataMatchScore, range));
return filterValues.some(range => isMatchScoreInRange(book.metadataMatchScore, range as string | number));
case 'library':
return mode === 'or'
? filterValues.some(val => val == book.libraryId)
@@ -109,39 +98,112 @@ export class SideBarFilter implements BookFilter {
return filterValues.includes(shelved);
case 'tag':
return mode === 'or'
? filterValues.some(val => book.metadata?.tags?.includes(val))
: filterValues.every(val => book.metadata?.tags?.includes(val));
? filterValues.some(val => book.metadata?.tags?.includes(val as string))
: filterValues.every(val => book.metadata?.tags?.includes(val as string));
case 'publishedDate':
const bookYear = book.metadata?.publishedDate
? new Date(book.metadata.publishedDate).getFullYear()
: null;
return bookYear ? filterValues.some(val => val == bookYear || val == bookYear.toString()) : false;
case 'fileSize':
return filterValues.some(range => isFileSizeInRange(book.fileSizeKb, range));
return filterValues.some(range => isFileSizeInRange(book.fileSizeKb, range as string | number));
case 'amazonRating':
return filterValues.some(range => isRatingInRange(book.metadata?.amazonRating, range));
return filterValues.some(range => isRatingInRange(book.metadata?.amazonRating, range as string | number));
case 'goodreadsRating':
return filterValues.some(range => isRatingInRange(book.metadata?.goodreadsRating, range));
return filterValues.some(range => isRatingInRange(book.metadata?.goodreadsRating, range as string | number));
case 'hardcoverRating':
return filterValues.some(range => isRatingInRange(book.metadata?.hardcoverRating, range));
return filterValues.some(range => isRatingInRange(book.metadata?.hardcoverRating, range as string | number));
case 'language':
return filterValues.includes(book.metadata?.language);
case 'pageCount':
return filterValues.some(range => isPageCountInRange(book.metadata?.pageCount!, range));
return filterValues.some(range => isPageCountInRange(book.metadata?.pageCount!, range as string | number));
case 'mood':
return mode === 'or'
? filterValues.some(val => book.metadata?.moods?.includes(val))
: filterValues.every(val => book.metadata?.moods?.includes(val));
? filterValues.some(val => book.metadata?.moods?.includes(val as string))
: filterValues.every(val => book.metadata?.moods?.includes(val as string));
case 'ageRating':
return filterValues.some(val => isAgeRatingMatch(book.metadata?.ageRating, val));
return filterValues.some(val => {
const numVal = typeof val === 'string' ? Number(val) : val;
return book.metadata?.ageRating === numVal;
});
case 'contentRating':
return filterValues.includes(book.metadata?.contentRating);
case 'comicCharacter':
return mode === 'or'
? filterValues.some(val => book.metadata?.comicMetadata?.characters?.includes(val as string))
: filterValues.every(val => book.metadata?.comicMetadata?.characters?.includes(val as string));
case 'comicTeam':
return mode === 'or'
? filterValues.some(val => book.metadata?.comicMetadata?.teams?.includes(val as string))
: filterValues.every(val => book.metadata?.comicMetadata?.teams?.includes(val as string));
case 'comicLocation':
return mode === 'or'
? filterValues.some(val => book.metadata?.comicMetadata?.locations?.includes(val as string))
: filterValues.every(val => book.metadata?.comicMetadata?.locations?.includes(val as string));
case 'comicCreator': {
const comic = book.metadata?.comicMetadata;
if (!comic) return false;
const allCreators: string[] = [];
const roles: [string[] | undefined, string][] = [
[comic.pencillers, 'penciller'],
[comic.inkers, 'inker'],
[comic.colorists, 'colorist'],
[comic.letterers, 'letterer'],
[comic.coverArtists, 'coverArtist'],
[comic.editors, 'editor']
];
for (const [names, role] of roles) {
if (names) {
for (const name of names) {
allCreators.push(`${name}:${role}`);
}
}
}
return mode === 'or'
? filterValues.some(val => allCreators.includes(val as string))
: filterValues.every(val => allCreators.includes(val as string));
}
default:
return false;
}
});
}
export function filterBooksByFilters(
books: Book[],
activeFilters: Record<string, unknown[]> | null,
mode: BookFilterMode,
excludeFilterType?: string
): Book[] {
if (!activeFilters) return books;
const filterEntries = Object.entries(activeFilters)
.filter(([type]) => type !== excludeFilterType);
if (filterEntries.length === 0) return books;
return books.filter(book => {
const matches = filterEntries.map(([filterType, filterValues]) =>
doesBookMatchFilter(book, filterType, filterValues, mode)
);
return mode === 'or' ? matches.some(m => m) : matches.every(m => m);
});
}
export class SideBarFilter implements BookFilter {
constructor(private selectedFilter$: Observable<unknown>, private selectedFilterMode$: Observable<BookFilterMode>) {
}
filter(bookState: BookState): Observable<BookState> {
return combineLatest([this.selectedFilter$, this.selectedFilterMode$]).pipe(
map(([activeFilters, mode]) => {
if (bookState.books == null) return bookState;
if (!activeFilters) return bookState;
const filteredBooks = filterBooksByFilters(
bookState.books || [],
activeFilters as Record<string, unknown[]>,
mode
);
return {...bookState, books: filteredBooks};
})
);

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="dialogRef.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="closeDialog()"
styleClass="close-button">
class="close-button">
</p-button>
</div>
@@ -50,7 +50,7 @@
[dropdown]="true"
[showClear]="true"
placeholder="Search for a book in the same library..."
styleClass="full-width"
class="full-width"
[disabled]="isAttaching">
<ng-template let-book pTemplate="item">
<div class="book-suggestion">

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="dynamicDialogRef.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>
@@ -27,7 +27,7 @@
placeholder="Select Email Provider"
[(ngModel)]="selectedProvider"
appendTo="body"
styleClass="full-width">
class="full-width">
</p-select>
</div>
@@ -40,7 +40,7 @@
[(ngModel)]="selectedRecipient"
[disabled]="!selectedProvider"
appendTo="body"
styleClass="full-width">
class="full-width">
</p-select>
</div>

View File

@@ -19,7 +19,7 @@
[rounded]="true"
severity="secondary"
(onClick)="closeDialog()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -15,7 +15,7 @@
[rounded]="true"
severity="secondary"
(onClick)="closeDialog()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -193,6 +193,7 @@ export interface BookMetadata {
tags?: string[];
provider?: string;
providerBookId?: string;
externalUrl?: string;
thumbnailUrl?: string | null;
reviews?: BookReview[];
titleLocked?: boolean;

View File

@@ -5,12 +5,13 @@ import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-
import {BookMetadata} from '../model/book.model';
import {AuthService} from '../../../shared/service/auth.service';
import {SseClient} from 'ngx-sse-client';
import {HttpHeaders} from '@angular/common/http';
import {HttpClient, HttpHeaders} from '@angular/common/http';
import {map} from 'rxjs/operators';
@Injectable({providedIn: 'root'})
export class BookMetadataService {
private readonly url = `${API_CONFIG.BASE_URL}/api/v1/books`;
private http = inject(HttpClient);
private authService = inject(AuthService);
private sseClient = inject(SseClient);
@@ -52,4 +53,8 @@ export class BookMetadataService {
})
);
}
fetchMetadataDetail(provider: string, providerItemId: string): Observable<BookMetadata> {
return this.http.get<BookMetadata>(`${this.url}/metadata/detail/${provider}/${providerItemId}`);
}
}

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="ref.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -40,7 +40,7 @@
[label]="getPlaceholderLabel(placeholder.name)"
[pTooltip]="getPlaceholderTooltip(placeholder)"
tooltipPosition="top"
styleClass="placeholder-chip"
class="placeholder-chip"
(click)="insertPlaceholder(placeholder.name)">
</p-chip>
}

View File

@@ -29,7 +29,7 @@
<p-button
label="Create Your Library"
icon="pi pi-plus"
styleClass="p-button-rounded p-button-outlined"
class="p-button-rounded p-button-outlined"
(click)="createNewLibrary()">
</p-button>
</div>

View File

@@ -15,7 +15,7 @@
[rounded]="true"
severity="secondary"
(onClick)="cancel()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -14,7 +14,7 @@
[text]="true"
severity="secondary"
(click)="ref?.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>
}

View File

@@ -161,6 +161,7 @@
<p-button pTooltip="Unlock Cover" tooltipPosition="top" size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('audiobookCover')" severity="warn"></p-button>
}
</div>
`
</div>
}
</div>
@@ -305,7 +306,7 @@
</div>
<div class="field-row">
<div class="field-group full-width">
<div class="field-group half-width">
<label for="moods">Moods</label>
<div class="field-with-lock">
<div class="autocomplete-wrapper">
@@ -329,9 +330,7 @@
}
</div>
</div>
</div>
<div class="field-row">
<div class="field-group full-width">
<div class="field-group half-width">
<label for="tags">Tags</label>
<div class="field-with-lock">
<div class="autocomplete-wrapper">
@@ -406,20 +405,45 @@
}
</div>
</div>
<div class="field-group small-field">
<label for="pageCount">Pages</label>
<label>Public Reviews</label>
<div class="field-with-lock">
<input pInputText id="pageCount" formControlName="pageCount"/>
@if (!book.metadata!['pageCountLocked']) {
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('pageCount')" severity="success"></p-button>
<input pSize="small" pInputText [disabled]="true" value="No Value"/>
@if (!book.metadata!['reviewsLocked']) {
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('reviews')" severity="success"></p-button>
}
@if (book.metadata!['pageCountLocked']) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('pageCount')" severity="warn"></p-button>
@if (book.metadata!['reviewsLocked']) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('reviews')" severity="warn"></p-button>
}
</div>
</div>
</div>
<div class="field-row">
<div class="field-group small-field">
<label for="isbn10">ISBN 10</label>
<div class="field-with-lock">
<input pInputText id="isbn10" formControlName="isbn10"/>
@if (!book.metadata!['isbn10Locked']) {
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="success"></p-button>
}
@if (book.metadata!['isbn10Locked']) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="warn"></p-button>
}
</div>
</div>
<div class="field-group small-field">
<label for="isbn13">ISBN 13</label>
<div class="field-with-lock">
<input pInputText id="isbn13" formControlName="isbn13"/>
@if (!book.metadata!['isbn13Locked']) {
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="success"></p-button>
}
@if (book.metadata!['isbn13Locked']) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="warn"></p-button>
}
</div>
</div>
<div class="field-group small-field">
<label for="ageRating">Age Rating</label>
<div class="field-with-lock">
@@ -441,7 +465,6 @@
}
</div>
</div>
<div class="field-group small-field">
<label for="contentRating">Content Rating</label>
<div class="field-with-lock">
@@ -463,47 +486,151 @@
}
</div>
</div>
<div class="field-group small-field">
<label for="pageCount">Pages</label>
<div class="field-with-lock">
<input pInputText id="pageCount" formControlName="pageCount"/>
@if (!book.metadata!['pageCountLocked']) {
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('pageCount')" severity="success"></p-button>
}
@if (book.metadata!['pageCountLocked']) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('pageCount')" severity="warn"></p-button>
}
</div>
</div>
</div>
</div>
</div>
<!-- Audiobook Metadata Section -->
@if (isAudiobook(book)) {
<div class="collapsible-section" [class.expanded]="audiobookSectionExpanded">
<div class="collapsible-header" (click)="audiobookSectionExpanded = !audiobookSectionExpanded">
<i class="pi pi-headphones"></i>
<span>Audiobook Details</span>
<i [class]="audiobookSectionExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="section-toggle"></i>
</div>
@if (audiobookSectionExpanded) {
<div class="collapsible-content">
<div class="field-row metadata-ids-row">
@for (field of audiobookMetadataFields; track field) {
<div class="field-group metadata-id-field">
<label for="reviews">Public Reviews</label>
<label>{{ field.label }}</label>
<div class="field-with-lock">
<input pSize="small" pInputText [disabled]="true" value="No Value"/>
@if (!book.metadata!['reviewsLocked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('reviews')" severity="success"></p-button>
@if (field.type === 'boolean') {
<p-select [formControlName]="field.controlName" [options]="booleanOptions" optionLabel="label" optionValue="value" appendTo="body"></p-select>
} @else {
<input pInputText [formControlName]="field.controlName"/>
}
@if (book.metadata!['reviewsLocked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('reviews')" severity="warn"></p-button>
@if (!metadataForm.get(field.lockedKey)?.value) {
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="success"></p-button>
}
@if (metadataForm.get(field.lockedKey)?.value) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
}
</div>
</div>
<div class="field-group metadata-id-field">
<label for="isbn10">ISBN 10</label>
}
</div>
</div>
}
</div>
}
<!-- Comic Book Metadata Section -->
@if (isCBX(book)) {
<div class="collapsible-section" [class.expanded]="comicSectionExpanded">
<div class="collapsible-header" (click)="comicSectionExpanded = !comicSectionExpanded">
<i class="pi pi-bolt"></i>
<span>Comic Book Details</span>
<i [class]="comicSectionExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="section-toggle"></i>
</div>
@if (comicSectionExpanded) {
<div class="collapsible-content">
<!-- Text/number/boolean fields in grid -->
<div class="comic-text-grid">
@for (field of comicTextFields; track field) {
<div class="field-group">
<label>{{ field.label }}</label>
<div class="field-with-lock">
<input pSize="small" pInputText id="isbn10" formControlName="isbn10"/>
@if (!book.metadata!['isbn10Locked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="success"></p-button>
@if (field.type === 'boolean') {
<p-select [formControlName]="field.controlName" [options]="booleanOptions" optionLabel="label" optionValue="value" size="small" appendTo="body"></p-select>
} @else {
<input pSize="small" pInputText [formControlName]="field.controlName"/>
}
@if (book.metadata!['isbn10Locked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn10')" severity="warn"></p-button>
@if (!metadataForm.get(field.lockedKey)?.value) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="success"></p-button>
}
@if (metadataForm.get(field.lockedKey)?.value) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
}
</div>
</div>
<div class="field-group metadata-id-field">
<label for="isbn13">ISBN 13</label>
}
</div>
<!-- Array fields (creators, characters, teams, locations) -->
<div class="comic-array-grid">
@for (field of comicArrayFields; track field) {
<div class="field-group">
<label>{{ field.label }}</label>
<div class="field-with-lock">
<input pSize="small" pInputText id="isbn13" formControlName="isbn13"/>
@if (!book.metadata!['isbn13Locked']) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="success"></p-button>
<div class="autocomplete-wrapper">
<p-autoComplete
[formControlName]="field.controlName"
[multiple]="true"
[dropdown]="false"
[suggestions]="[]"
[forceSelection]="false"
[showClear]="true"
(onKeyUp)="onAutoCompleteKeyUp(field.controlName, $event)"
(onSelect)="onAutoCompleteSelect(field.controlName, $event)">
</p-autoComplete>
</div>
@if (!metadataForm.get(field.lockedKey)?.value) {
<p-button icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="success"></p-button>
}
@if (book.metadata!['isbn13Locked']) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock('isbn13')" severity="warn"></p-button>
@if (metadataForm.get(field.lockedKey)?.value) {
<p-button icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
}
</div>
</div>
}
</div>
<!-- Textarea fields (notes) -->
@for (field of comicTextareaFields; track field) {
<div class="field-row comic-array-row">
<div class="field-group full-width">
<label>{{ field.label }}</label>
<div class="field-with-lock description-with-lock">
<textarea pTextarea [formControlName]="field.controlName" rows="4"></textarea>
@if (!metadataForm.get(field.lockedKey)?.value) {
<p-button size="small" icon="pi pi-lock-open" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="success"></p-button>
}
@if (metadataForm.get(field.lockedKey)?.value) {
<p-button size="small" icon="pi pi-lock" [outlined]="true" (onClick)="toggleLock(field.controlName)" severity="warn"></p-button>
}
</div>
</div>
</div>
}
</div>
}
</div>
}
<!-- Provider Metadata Section (collapsible) -->
<div class="collapsible-section" [class.expanded]="providerSectionExpanded">
<div class="collapsible-header" (click)="providerSectionExpanded = !providerSectionExpanded">
<i class="pi pi-database"></i>
<span>Provider Metadata</span>
<i [class]="providerSectionExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="section-toggle"></i>
</div>
@if (providerSectionExpanded) {
<div class="collapsible-content">
<div class="field-row metadata-ids-row">
@if (isFieldVisible('asin')) {
<div class="field-group metadata-id-field">
<label for="asin">Amazon ASIN</label>
@@ -771,6 +898,9 @@
</div>
}
</div>
</div>
}
</div>
<div class="description-section">
<div class="field-group description-field">

View File

@@ -239,7 +239,7 @@ label {
.language-field {
@media (min-width: 768px) {
width: 9rem;
width: 11rem;
}
}
@@ -271,21 +271,31 @@ label {
width: 100%;
}
.half-width {
width: 100%;
@media (min-width: 768px) {
flex: 1;
}
}
.series-name-field {
@media (min-width: 768px) {
flex-basis: 75%;
flex: 4.75;
min-width: 0;
}
}
.small-field {
@media (min-width: 768px) {
width: 9rem;
flex: 1;
min-width: 0;
}
}
.metadata-ids-row {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
width: 100%;
@@ -300,6 +310,76 @@ label {
}
}
.collapsible-section {
margin-top: 1rem;
width: 100%;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
.collapsible-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
min-height: 2.5rem;
background: var(--surface-ground);
border-radius: 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-color);
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
&:hover {
background: var(--surface-hover);
}
i:first-child {
font-size: 0.9rem;
color: var(--primary-color);
}
.section-toggle {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-secondary-color);
}
}
&.expanded .collapsible-header {
border-radius: 0.75rem 0.75rem 0 0;
}
.collapsible-content {
padding: 0.75rem;
border-top: 1px solid var(--border-color);
}
}
.comic-text-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
margin-bottom: 1rem;
@media (max-width: 767px) {
grid-template-columns: repeat(2, 1fr);
}
}
.comic-array-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
}
.description-section {
display: flex;
flex-direction: column;
@@ -335,7 +415,8 @@ label {
input,
p-autocomplete,
p-datepicker {
p-datepicker,
p-select {
width: 100%;
}
}

View File

@@ -6,8 +6,9 @@ import {FormControl, FormGroup, FormsModule, ReactiveFormsModule,} from "@angula
import {Observable, sample} from "rxjs";
import {AsyncPipe} from "@angular/common";
import {MessageService} from "primeng/api";
import {Book, BookMetadata, MetadataClearFlags, MetadataUpdateWrapper,} from "../../../../book/model/book.model";
import {Book, BookMetadata, ComicMetadata, MetadataClearFlags, MetadataUpdateWrapper,} from "../../../../book/model/book.model";
import {UrlHelperService} from "../../../../../shared/service/url-helper.service";
import {ALL_COMIC_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, COMIC_FORM_TO_MODEL_LOCK, COMIC_TEXT_METADATA_FIELDS, COMIC_ARRAY_METADATA_FIELDS, COMIC_TEXTAREA_METADATA_FIELDS, MetadataFieldConfig} from '../../../../../shared/metadata';
import {FileUpload, FileUploadErrorEvent, FileUploadEvent,} from "primeng/fileupload";
import {HttpResponse} from "@angular/common/http";
import {BookService} from "../../../../book/service/book.service";
@@ -106,6 +107,20 @@ export class MetadataEditorComponent implements OnInit {
contentRatingOptions: {label: string, value: string}[] = [];
ageRatingOptions: {label: string, value: number}[] = [];
booleanOptions: {label: string, value: boolean | null}[] = [
{label: 'Unknown', value: null},
{label: 'Yes', value: true},
{label: 'No', value: false},
];
comicSectionExpanded = true;
audiobookSectionExpanded = true;
providerSectionExpanded = true;
comicTextFields = COMIC_TEXT_METADATA_FIELDS;
comicArrayFields = COMIC_ARRAY_METADATA_FIELDS;
comicTextareaFields = COMIC_TEXTAREA_METADATA_FIELDS;
audiobookMetadataFields = AUDIOBOOK_METADATA_FIELDS;
providerSpecificFields: MetadataProviderSpecificFields = {
asin: true,
@@ -274,6 +289,20 @@ export class MetadataEditorComponent implements OnInit {
{label: '18+', value: 18},
{label: '21+', value: 21}
];
// Add audiobook metadata form controls
for (const field of AUDIOBOOK_METADATA_FIELDS) {
const defaultValue = field.type === 'boolean' ? null : '';
this.metadataForm.addControl(field.controlName, new FormControl(defaultValue));
this.metadataForm.addControl(field.lockedKey, new FormControl(false));
}
// Add comic metadata form controls
for (const field of ALL_COMIC_METADATA_FIELDS) {
const defaultValue = field.type === 'array' ? [] : (field.type === 'number' || field.type === 'boolean') ? null : '';
this.metadataForm.addControl(field.controlName, new FormControl(defaultValue));
this.metadataForm.addControl(field.lockedKey, new FormControl(false));
}
}
ngOnInit(): void {
@@ -434,6 +463,33 @@ export class MetadataEditorComponent implements OnInit {
contentRatingLocked: metadata.contentRatingLocked ?? false,
});
// Patch audiobook metadata
const audiobookPatch: Record<string, unknown> = {};
for (const field of AUDIOBOOK_METADATA_FIELDS) {
const key = field.controlName as keyof BookMetadata;
const lockedKey = field.lockedKey as keyof BookMetadata;
audiobookPatch[field.controlName] = metadata[key] ?? (field.type === 'boolean' ? null : '');
audiobookPatch[field.lockedKey] = metadata[lockedKey] ?? false;
}
this.metadataForm.patchValue(audiobookPatch);
// Patch comic metadata
const comicMeta = metadata.comicMetadata;
const comicPatch: Record<string, unknown> = {};
for (const field of ALL_COMIC_METADATA_FIELDS) {
const value = comicMeta?.[field.fetchedKey as keyof ComicMetadata];
if (field.type === 'array') {
comicPatch[field.controlName] = [...(value as string[] ?? [])].sort();
} else if (field.type === 'boolean' || field.type === 'number') {
comicPatch[field.controlName] = value ?? null;
} else {
comicPatch[field.controlName] = value ?? '';
}
const modelLockKey = COMIC_FORM_TO_MODEL_LOCK[field.lockedKey];
comicPatch[field.lockedKey] = comicMeta?.[modelLockKey as keyof ComicMetadata] ?? false;
}
this.metadataForm.patchValue(comicPatch);
const lockableFields: { key: keyof BookMetadata; control: string }[] = [
{key: "titleLocked", control: "title"},
{key: "subtitleLocked", control: "subtitle"},
@@ -485,6 +541,24 @@ export class MetadataEditorComponent implements OnInit {
isLocked ? formControl.disable() : formControl.enable();
}
}
// Apply audiobook lock states
for (const field of AUDIOBOOK_METADATA_FIELDS) {
const isLocked = this.metadataForm.get(field.lockedKey)?.value === true;
const formControl = this.metadataForm.get(field.controlName);
if (formControl) {
isLocked ? formControl.disable() : formControl.enable();
}
}
// Apply comic lock states
for (const field of ALL_COMIC_METADATA_FIELDS) {
const isLocked = this.metadataForm.get(field.lockedKey)?.value === true;
const formControl = this.metadataForm.get(field.controlName);
if (formControl) {
isLocked ? formControl.disable() : formControl.enable();
}
}
}
onAutoCompleteSelect(fieldName: string, event: AutoCompleteSelectEvent) {
@@ -628,6 +702,12 @@ export class MetadataEditorComponent implements OnInit {
thumbnailUrl: form.get("thumbnailUrl")?.value,
audiobookCover: form.get("audiobookCover")?.value,
// Audiobook metadata
narrator: form.get("narrator")?.value,
abridged: form.get("abridged")?.value,
narratorLocked: form.get("narratorLocked")?.value,
abridgedLocked: form.get("abridgedLocked")?.value,
// Locks
titleLocked: form.get("titleLocked")?.value,
subtitleLocked: form.get("subtitleLocked")?.value,
@@ -677,6 +757,35 @@ export class MetadataEditorComponent implements OnInit {
}),
};
// Build comic metadata from form controls
const comicMetadata: Record<string, unknown> = {};
for (const field of ALL_COMIC_METADATA_FIELDS) {
const value = form.get(field.controlName)?.value;
if (field.type === 'array') {
comicMetadata[field.fetchedKey] = Array.isArray(value) ? value : [];
} else if (field.type === 'number') {
comicMetadata[field.fetchedKey] = value !== '' && value !== null ? Number(value) : null;
} else if (field.type === 'boolean') {
comicMetadata[field.fetchedKey] = value ?? null;
} else {
comicMetadata[field.fetchedKey] = value ?? '';
}
}
// Consolidate comic lock states to model lock keys
const lockGroups: Record<string, boolean> = {};
for (const [formKey, modelKey] of Object.entries(COMIC_FORM_TO_MODEL_LOCK)) {
const value = form.get(formKey)?.value ?? false;
if (value) {
lockGroups[modelKey] = true;
} else if (!(modelKey in lockGroups)) {
lockGroups[modelKey] = false;
}
}
for (const [modelKey, value] of Object.entries(lockGroups)) {
comicMetadata[modelKey] = value;
}
metadata.comicMetadata = comicMetadata as ComicMetadata;
const original = this.originalMetadata;
const wasCleared = (key: keyof BookMetadata): boolean => {
@@ -725,6 +834,8 @@ export class MetadataEditorComponent implements OnInit {
seriesName: wasCleared("seriesName"),
seriesNumber: wasCleared("seriesNumber"),
seriesTotal: wasCleared("seriesTotal"),
narrator: wasCleared("narrator"),
abridged: wasCleared("abridged"),
audiobookCover: wasCleared("audiobookCover"),
cover: false,
ageRating: wasCleared("ageRating"),
@@ -1051,6 +1162,14 @@ export class MetadataEditorComponent implements OnInit {
return this.bookService.supportsDualCovers(book);
}
isCBX(book: Book): boolean {
return book.primaryFile?.bookType === 'CBX';
}
isAudiobook(book: Book): boolean {
return book.primaryFile?.bookType === 'AUDIOBOOK';
}
getUploadAudiobookCoverUrl(): string {
return this.bookService.getUploadAudiobookCoverUrl(this.currentBookId);
}

View File

@@ -1,6 +1,13 @@
@if (book$ | async; as book) {
<form [formGroup]="metadataForm" (ngSubmit)="onSave()" class="metadata-picker">
<div class="dialog-body">
@if (detailLoading) {
<div class="detail-loading-banner">
<i class="pi pi-spin pi-spinner"></i>
<span>Loading complete metadata...</span>
</div>
}
<!-- Column Headers (Desktop) -->
<div class="column-headers desktop-only">
<div class="column-label current">
@@ -434,16 +441,22 @@
</div>
</section>
<!-- Provider Metadata Section -->
@if (metadataProviderFields.length > 0) {
<!-- Comic Book Details Section -->
@if (shouldShowComicSection()) {
<section class="form-section">
<div class="section-header">
<i class="pi pi-cloud"></i>
<span>Provider Metadata</span>
<div class="section-header collapsible-header" (click)="comicSectionExpanded = !comicSectionExpanded">
<i class="pi pi-bolt"></i>
<span>Comic Book Details</span>
@if (!hasAnyFetchedComicData() && !hasAnyCurrentComicData()) {
<span class="section-empty-badge">none present</span>
}
<i [class]="comicSectionExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" class="section-toggle"></i>
</div>
@if (comicSectionExpanded) {
<div class="section-content">
@for (field of metadataProviderFields; track field) {
<!-- Comic text/number/boolean fields -->
@for (field of comicTextFields; track field) {
<div class="field-row">
<label class="field-label">{{ field.label }}</label>
<div class="field-content">
@@ -456,7 +469,15 @@
>
<i [class]="metadataForm.get(field.lockedKey)?.value ? 'pi pi-lock' : 'pi pi-lock-open'"></i>
</button>
@if (field.type === 'boolean') {
<p-checkbox
formControlName="{{field.controlName}}"
[binary]="true"
class="field-checkbox"
/>
} @else {
<input pInputText formControlName="{{field.controlName}}" class="field-input"/>
}
</div>
<button
@@ -473,12 +494,117 @@
</button>
<div class="field-side fetched">
<input pInputText [value]="fetchedMetadata[field.fetchedKey] ?? ''" class="field-input" readonly/>
@if (field.type === 'boolean') {
<p-checkbox
[ngModel]="getFetchedComicValue(field.fetchedKey)"
[ngModelOptions]="{ standalone: true }"
[binary]="true"
[disabled]="true"
class="field-checkbox"
/>
} @else {
<input pInputText [value]="getFetchedComicValue(field.fetchedKey) ?? ''" class="field-input" readonly/>
}
</div>
</div>
</div>
}
<!-- Comic array fields (creators, characters, teams, locations) -->
@for (field of comicArrayFields; track field) {
<div class="field-row">
<label class="field-label">{{ field.label }}</label>
<div class="field-content">
<div class="field-side current">
<button
type="button"
class="lock-btn"
[class.locked]="metadataForm.get(field.lockedKey)?.value"
(click)="toggleLock(field.controlName)"
>
<i [class]="metadataForm.get(field.lockedKey)?.value ? 'pi pi-lock' : 'pi pi-lock-open'"></i>
</button>
<p-autoComplete
class="chips-input"
formControlName="{{field.controlName}}"
[multiple]="true"
[dropdown]="false"
[forceSelection]="false"
[showClear]="true"
[suggestions]="getFiltered(field.controlName)"
(completeMethod)="filterItems($event, field.controlName)"
(onKeyUp)="onAutoCompleteKeyUp(field.controlName, $event)"
(onSelect)="onAutoCompleteSelect(field.controlName, $event)"
/>
</div>
<button
type="button"
class="transfer-btn"
[class.saved]="isValueSaved(field.controlName)"
[class.copied]="isValueCopied(field.controlName) && !hoveredFields[field.controlName]"
[class.reset]="isValueCopied(field.controlName) && hoveredFields[field.controlName]"
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
(mouseenter)="onMouseEnter(field.controlName)"
(mouseleave)="onMouseLeave(field.controlName)"
>
<i [class]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"></i>
</button>
<div class="field-side fetched">
<p-autoComplete
[ngModel]="getFetchedComicValue(field.fetchedKey) ?? []"
[ngModelOptions]="{ standalone: true }"
[disabled]="true"
[multiple]="true"
[typeahead]="false"
[dropdown]="false"
[forceSelection]="false"
class="chips-input readonly"
/>
</div>
</div>
</div>
}
<!-- Comic textarea fields (notes) -->
@for (field of comicTextareaFields; track field) {
<div class="field-row textarea-row">
<label class="field-label">{{ field.label }}</label>
<div class="field-content">
<div class="field-side current">
<button
type="button"
class="lock-btn"
[class.locked]="metadataForm.get(field.lockedKey)?.value"
(click)="toggleLock(field.controlName)"
>
<i [class]="metadataForm.get(field.lockedKey)?.value ? 'pi pi-lock' : 'pi pi-lock-open'"></i>
</button>
<textarea pTextarea formControlName="{{field.controlName}}" class="field-textarea" rows="3"></textarea>
</div>
<button
type="button"
class="transfer-btn"
[class.saved]="isValueSaved(field.controlName)"
[class.copied]="isValueCopied(field.controlName) && !hoveredFields[field.controlName]"
[class.reset]="isValueCopied(field.controlName) && hoveredFields[field.controlName]"
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
(mouseenter)="onMouseEnter(field.controlName)"
(mouseleave)="onMouseLeave(field.controlName)"
>
<i [class]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"></i>
</button>
<div class="field-side fetched">
<textarea pTextarea [value]="getFetchedComicValue(field.fetchedKey) ?? ''" class="field-textarea" rows="3" readonly></textarea>
</div>
</div>
</div>
}
</div>
}
</section>
}
@@ -547,6 +673,54 @@
</div>
</section>
}
<!-- Provider Metadata Section -->
@if (metadataProviderFields.length > 0) {
<section class="form-section">
<div class="section-header">
<i class="pi pi-cloud"></i>
<span>Provider Metadata</span>
</div>
<div class="section-content">
@for (field of metadataProviderFields; track field) {
<div class="field-row">
<label class="field-label">{{ field.label }}</label>
<div class="field-content">
<div class="field-side current">
<button
type="button"
class="lock-btn"
[class.locked]="metadataForm.get(field.lockedKey)?.value"
(click)="toggleLock(field.controlName)"
>
<i [class]="metadataForm.get(field.lockedKey)?.value ? 'pi pi-lock' : 'pi pi-lock-open'"></i>
</button>
<input pInputText formControlName="{{field.controlName}}" class="field-input"/>
</div>
<button
type="button"
class="transfer-btn"
[class.saved]="isValueSaved(field.controlName)"
[class.copied]="isValueCopied(field.controlName) && !hoveredFields[field.controlName]"
[class.reset]="isValueCopied(field.controlName) && hoveredFields[field.controlName]"
(click)="hoveredFields[field.controlName] && isValueCopied(field.controlName) ? resetField(field.controlName) : copyFetchedToCurrent(field.controlName)"
(mouseenter)="onMouseEnter(field.controlName)"
(mouseleave)="onMouseLeave(field.controlName)"
>
<i [class]="isValueSaved(field.controlName) ? 'pi pi-check' : (hoveredFields[field.controlName] && isValueCopied(field.controlName) ? 'pi pi-times' : 'pi pi-arrow-left')"></i>
</button>
<div class="field-side fetched">
<input pInputText [value]="fetchedMetadata[field.fetchedKey] ?? ''" class="field-input" readonly/>
</div>
</div>
</div>
}
</div>
</section>
}
</div>
@if (!reviewMode) {

View File

@@ -25,6 +25,26 @@
}
// Detail loading banner
.detail-loading-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 8px;
color: var(--p-green-400);
font-size: 0.875rem;
font-weight: 600;
animation: fadeIn 0.3s ease-in;
i {
font-size: 1rem;
}
}
// Body
.dialog-body {
flex: 1;
@@ -128,6 +148,35 @@
font-size: 0.9rem;
color: var(--primary-color);
}
&.collapsible-header {
cursor: pointer;
user-select: none;
transition: color 0.2s ease;
&:hover {
color: var(--text-color);
}
.section-toggle {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-secondary-color);
}
}
.section-empty-badge {
font-size: 0.7rem;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
color: var(--text-secondary-color);
background: var(--surface-ground);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.125rem 0.5rem;
opacity: 0.8;
}
}
.section-content {

View File

@@ -1,5 +1,5 @@
import {Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output} from '@angular/core';
import {Book, BookMetadata, MetadataClearFlags, MetadataUpdateWrapper} from '../../../../book/model/book.model';
import {Book, BookMetadata, ComicMetadata, MetadataClearFlags, MetadataUpdateWrapper} from '../../../../book/model/book.model';
import {MessageService} from 'primeng/api';
import {Button} from 'primeng/button';
import {FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
@@ -18,7 +18,7 @@ import {Checkbox} from 'primeng/checkbox';
import {LazyLoadImageModule} from 'ng-lazyload-image';
import {AppSettingsService} from '../../../../../shared/service/app-settings.service';
import {MetadataProviderSpecificFields} from '../../../../../shared/model/app-settings.model';
import {ALL_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, getArrayFields, getBookDetailsFields, getBottomFields, getProviderFields, getSeriesFields, getTextareaFields, getTopFields, MetadataFieldConfig, MetadataFormBuilder, MetadataUtilsService} from '../../../../../shared/metadata';
import {ALL_COMIC_METADATA_FIELDS, ALL_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, COMIC_ARRAY_METADATA_FIELDS, COMIC_FORM_TO_MODEL_LOCK, COMIC_TEXT_METADATA_FIELDS, COMIC_TEXTAREA_METADATA_FIELDS, getArrayFields, getBookDetailsFields, getBottomFields, getProviderFields, getSeriesFields, getTextareaFields, getTopFields, MetadataFieldConfig, MetadataFormBuilder, MetadataUtilsService} from '../../../../../shared/metadata';
@Component({
selector: 'app-metadata-picker',
@@ -50,10 +50,15 @@ export class MetadataPickerComponent implements OnInit {
metadataProviderFields: MetadataFieldConfig[] = [];
metadataFieldsBottom: MetadataFieldConfig[] = [];
audiobookMetadataFields: MetadataFieldConfig[] = [];
comicTextFields: MetadataFieldConfig[] = [];
comicArrayFields: MetadataFieldConfig[] = [];
comicTextareaFields: MetadataFieldConfig[] = [];
comicSectionExpanded = false;
@Input() reviewMode!: boolean;
@Input() fetchedMetadata!: BookMetadata;
@Input() book$!: Observable<Book | null>;
@Input() detailLoading = false;
@Output() goBack = new EventEmitter<boolean>();
currentBook: Book | null = null;
@@ -93,6 +98,9 @@ export class MetadataPickerComponent implements OnInit {
this.updateProviderFields();
this.updateBottomFields();
this.audiobookMetadataFields = AUDIOBOOK_METADATA_FIELDS;
this.comicTextFields = COMIC_TEXT_METADATA_FIELDS;
this.comicArrayFields = COMIC_ARRAY_METADATA_FIELDS;
this.comicTextareaFields = COMIC_TEXTAREA_METADATA_FIELDS;
}
private updateProviderFields(): void {
@@ -209,8 +217,26 @@ export class MetadataPickerComponent implements OnInit {
patchData[field.lockedKey] = topLevelLocked ?? false;
}
// Handle comic book metadata fields (nested under comicMetadata)
const comicMeta = metadata.comicMetadata;
for (const field of ALL_COMIC_METADATA_FIELDS) {
const value = comicMeta?.[field.fetchedKey as keyof ComicMetadata];
if (field.type === 'array') {
patchData[field.controlName] = [...(value as string[] ?? [])].sort();
} else if (field.type === 'boolean') {
patchData[field.controlName] = value ?? null;
} else if (field.type === 'number') {
patchData[field.controlName] = value ?? null;
} else {
patchData[field.controlName] = value ?? '';
}
const modelLockKey = COMIC_FORM_TO_MODEL_LOCK[field.lockedKey];
patchData[field.lockedKey] = comicMeta?.[modelLockKey as keyof ComicMetadata] ?? false;
}
this.metadataForm.patchValue(patchData);
this.applyLockStates(metadata);
this.comicSectionExpanded = this.hasAnyFetchedComicData() || this.hasAnyCurrentComicData();
}
private applyLockStates(metadata: BookMetadata): void {
@@ -222,6 +248,12 @@ export class MetadataPickerComponent implements OnInit {
for (const field of AUDIOBOOK_METADATA_FIELDS) {
lockedFields[field.lockedKey] = !!metadata[field.lockedKey as keyof BookMetadata];
}
// Handle comic book metadata lock states (nested under comicMetadata)
const comicMeta = metadata.comicMetadata;
for (const field of ALL_COMIC_METADATA_FIELDS) {
const modelLockKey = COMIC_FORM_TO_MODEL_LOCK[field.lockedKey];
lockedFields[field.lockedKey] = !!comicMeta?.[modelLockKey as keyof ComicMetadata];
}
this.formBuilder.applyLockStates(this.metadataForm, lockedFields);
}
@@ -326,6 +358,35 @@ export class MetadataPickerComponent implements OnInit {
metadata[field.lockedKey] = this.metadataForm.get(field.lockedKey)?.value ?? false;
}
// Build comic metadata from form controls
const comicMetadata: Record<string, unknown> = {};
for (const field of ALL_COMIC_METADATA_FIELDS) {
if (field.type === 'array') {
comicMetadata[field.fetchedKey] = this.getArrayValue(field.controlName);
} else if (field.type === 'number') {
comicMetadata[field.fetchedKey] = this.getNumberValue(field.controlName);
} else if (field.type === 'boolean') {
comicMetadata[field.fetchedKey] = this.getBooleanValue(field.controlName);
} else {
comicMetadata[field.fetchedKey] = this.getStringValue(field.controlName);
}
}
// Consolidate lock states back to model lock keys using the form-to-model mapping.
// If ANY field in a group is locked, the backend group lock is set to true.
const lockGroups: Record<string, boolean> = {};
for (const [formKey, modelKey] of Object.entries(COMIC_FORM_TO_MODEL_LOCK)) {
const value = this.metadataForm.get(formKey)?.value ?? false;
if (value) {
lockGroups[modelKey] = true;
} else if (!(modelKey in lockGroups)) {
lockGroups[modelKey] = false;
}
}
for (const [modelKey, value] of Object.entries(lockGroups)) {
comicMetadata[modelKey] = value;
}
metadata['comicMetadata'] = comicMetadata;
return metadata as BookMetadata;
}
@@ -411,6 +472,27 @@ export class MetadataPickerComponent implements OnInit {
}
}
// Handle comic metadata clear flags
const currComic = current.comicMetadata;
const origComic = original.comicMetadata;
if (origComic) {
for (const field of ALL_COMIC_METADATA_FIELDS) {
const key = field.fetchedKey as keyof ComicMetadata;
const curr = currComic?.[key];
const orig = origComic[key];
if (field.type === 'array') {
flags[`comic_${key}`] = !(curr as string[])?.length && !!(orig as string[])?.length;
} else if (field.type === 'boolean') {
flags[`comic_${key}`] = curr === null && orig !== null;
} else if (field.type === 'number') {
flags[`comic_${key}`] = curr === null && orig !== null;
} else {
flags[`comic_${key}`] = !curr && !!orig;
}
}
}
return flags as MetadataClearFlags;
}
@@ -478,6 +560,21 @@ export class MetadataPickerComponent implements OnInit {
this.copiedFields,
(field) => this.copyFetchedToCurrent(field)
);
// Also copy missing comic fields
if (this.fetchedMetadata?.comicMetadata) {
for (const field of ALL_COMIC_METADATA_FIELDS) {
const isLocked = this.metadataForm.get(field.lockedKey)?.value;
const currentValue = this.metadataForm.get(field.controlName)?.value;
const fetchedValue = this.fetchedMetadata.comicMetadata[field.fetchedKey as keyof ComicMetadata];
const isEmpty = Array.isArray(currentValue)
? currentValue.length === 0
: (currentValue === null || currentValue === undefined || currentValue === '');
const hasFetchedValue = fetchedValue !== null && fetchedValue !== undefined && fetchedValue !== '';
if (!isLocked && isEmpty && hasFetchedValue) {
this.copyFetchedToCurrent(field.controlName);
}
}
}
}
copyAll(): void {
@@ -486,9 +583,40 @@ export class MetadataPickerComponent implements OnInit {
this.metadataForm,
(field) => this.copyFetchedToCurrent(field)
);
// Also copy all comic fields
if (this.fetchedMetadata?.comicMetadata) {
for (const field of ALL_COMIC_METADATA_FIELDS) {
const isLocked = this.metadataForm.get(field.lockedKey)?.value;
const fetchedValue = this.fetchedMetadata.comicMetadata[field.fetchedKey as keyof ComicMetadata];
if (!isLocked && fetchedValue != null && fetchedValue !== '') {
this.copyFetchedToCurrent(field.controlName);
}
}
}
}
copyFetchedToCurrent(field: string): void {
// Handle comic fields (nested under comicMetadata)
const comicConfig = this.getComicFieldConfig(field);
if (comicConfig) {
const isLocked = this.metadataForm.get(comicConfig.lockedKey)?.value;
if (isLocked) {
this.messageService.add({
severity: 'warn',
summary: 'Action Blocked',
detail: `${comicConfig.label} is locked and cannot be updated.`
});
return;
}
const value = this.fetchedMetadata?.comicMetadata?.[comicConfig.fetchedKey as keyof ComicMetadata];
if (value !== null && value !== undefined && value !== '') {
this.metadataForm.get(field)?.setValue(value);
this.copiedFields[field] = true;
this.highlightCopiedInput(field);
}
return;
}
let lockField = field;
if (field === 'thumbnailUrl') {
lockField = 'cover';
@@ -552,6 +680,14 @@ export class MetadataPickerComponent implements OnInit {
}
resetField(field: string): void {
const comicConfig = this.getComicFieldConfig(field);
if (comicConfig) {
const value = this.originalMetadata?.comicMetadata?.[comicConfig.fetchedKey as keyof ComicMetadata];
this.metadataForm.get(field)?.setValue(value ?? (comicConfig.type === 'array' ? [] : comicConfig.type === 'boolean' ? null : ''));
this.copiedFields[field] = false;
this.hoveredFields[field] = false;
return;
}
this.metadataUtils.resetField(field, this.metadataForm, this.originalMetadata, this.copiedFields, this.hoveredFields);
}
@@ -580,4 +716,41 @@ export class MetadataPickerComponent implements OnInit {
supportsDualCovers(book: Book): boolean {
return this.hasEbookFormat(book) && this.hasAudiobookFormat(book);
}
// Comic metadata helpers
hasAnyFetchedComicData(): boolean {
const comic = this.fetchedMetadata?.comicMetadata;
if (!comic) return false;
return ALL_COMIC_METADATA_FIELDS.some(field => {
const value = comic[field.fetchedKey as keyof ComicMetadata];
if (value === null || value === undefined || value === '' || value === false) return false;
if (Array.isArray(value) && value.length === 0) return false;
return true;
});
}
hasAnyCurrentComicData(): boolean {
const comic = this.currentBook?.metadata?.comicMetadata;
if (!comic) return false;
return ALL_COMIC_METADATA_FIELDS.some(field => {
const value = comic[field.fetchedKey as keyof ComicMetadata];
if (value === null || value === undefined || value === '' || value === false) return false;
if (Array.isArray(value) && value.length === 0) return false;
return true;
});
}
shouldShowComicSection(): boolean {
return this.hasAnyFetchedComicData() || this.hasAnyCurrentComicData() ||
this.currentBook?.primaryFile?.bookType === 'CBX' ||
this.fetchedMetadata?.provider?.toLowerCase() === 'comicvine';
}
getFetchedComicValue(fetchedKey: string): unknown {
return this.fetchedMetadata?.comicMetadata?.[fetchedKey as keyof ComicMetadata];
}
private getComicFieldConfig(controlName: string): MetadataFieldConfig | undefined {
return ALL_COMIC_METADATA_FIELDS.find(f => f.controlName === controlName);
}
}

View File

@@ -3,6 +3,7 @@
<app-metadata-picker
[fetchedMetadata]="selectedFetchedMetadata"
[book$]="book$"
[detailLoading]="detailLoading"
(goBack)="onGoBack()">
</app-metadata-picker>
</div>

View File

@@ -44,6 +44,7 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
@Input() book$!: Observable<Book | null>;
selectedFetchedMetadata$ = new BehaviorSubject<BookMetadata | null>(null);
detailLoading = false;
private formBuilder = inject(FormBuilder);
private bookMetadataService = inject(BookMetadataService);
@@ -267,7 +268,7 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
if (metadata.comicvineId) return 'comicvine';
if (metadata.ranobedbId) return 'ranobedb';
if (metadata.audibleId) return 'audible';
return null;
return metadata.provider?.toLowerCase() || null;
}
getProviderClass(metadata: BookMetadata): string {
@@ -341,9 +342,64 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
onBookClick(fetchedMetadata: BookMetadata) {
this.selectedFetchedMetadata$.next(fetchedMetadata);
const enrichment = this.getDetailEnrichmentInfo(fetchedMetadata);
if (enrichment) {
this.detailLoading = true;
this.bookMetadataService.fetchMetadataDetail(enrichment.provider, enrichment.id)
.pipe(takeUntil(this.cancelRequest$))
.subscribe({
next: (enriched) => {
const current = this.selectedFetchedMetadata$.value;
const currentId = current && this.getProviderItemId(current, enrichment.provider);
if (currentId === enrichment.id) {
this.selectedFetchedMetadata$.next(enriched);
}
this.detailLoading = false;
},
error: (err) => {
console.error('Error fetching detailed metadata:', err);
this.detailLoading = false;
}
});
}
}
private getDetailEnrichmentInfo(metadata: BookMetadata): { provider: string; id: string } | null {
if (metadata.comicvineId && (!metadata.comicMetadata
|| (!metadata.comicMetadata.pencillers?.length
&& !metadata.comicMetadata.inkers?.length
&& !metadata.comicMetadata.colorists?.length
&& !metadata.comicMetadata.letterers?.length
&& !metadata.comicMetadata.editors?.length
&& !metadata.comicMetadata.characters?.length))) {
return {provider: 'Comicvine', id: metadata.comicvineId};
}
if (metadata.goodreadsId && !metadata.description) {
return {provider: 'GoodReads', id: metadata.goodreadsId};
}
if (metadata.asin && !metadata.description) {
return {provider: 'Amazon', id: metadata.asin};
}
if (metadata.audibleId && !metadata.description) {
return {provider: 'Audible', id: metadata.audibleId};
}
return null;
}
private getProviderItemId(metadata: BookMetadata, provider: string): string | undefined {
switch (provider) {
case 'Comicvine': return metadata.comicvineId;
case 'GoodReads': return metadata.goodreadsId;
case 'Amazon': return metadata.asin;
case 'Audible': return metadata.audibleId;
default: return undefined;
}
}
onGoBack() {
this.detailLoading = false;
this.selectedFetchedMetadata$.next(null);
}
@@ -371,16 +427,17 @@ export class MetadataSearcherComponent implements OnInit, OnDestroy {
} else if (metadata['lubimyczytacId']) {
return `<a href="https://lubimyczytac.pl/ksiazka/${metadata['lubimyczytacId']}/ksiazka" target="_blank">Lubimyczytac</a>`;
} else if (metadata.comicvineId) {
if (metadata.comicvineId.startsWith('4000')) {
const name = metadata.seriesName ? metadata.seriesName.replace(/ /g, '-').toLowerCase() + "-" + metadata.seriesNumber : '';
return `<a href="https://comicvine.gamespot.com/${name}/${metadata.comicvineId}" target="_blank">Comicvine</a>`;
if (metadata.externalUrl) {
return `<a href="${metadata.externalUrl}" target="_blank">Comicvine</a>`;
}
const name = metadata.seriesName;
return `<a href="https://comicvine.gamespot.com/volume/${metadata.comicvineId}" target="_blank">Comicvine</a>`;
return `<a href="https://comicvine.gamespot.com/4050-${metadata.comicvineId}/" target="_blank">Comicvine</a>`;
} else if (metadata.ranobedbId) {
return `<a href="https://ranobedb.org/book/${metadata.ranobedbId}" target="_blank">RanobeDB</a>`;
} else if (metadata.audibleId) {
return `<a href="https://www.audible.com/pd/${metadata.audibleId}" target="_blank">Audible</a>`;
} else if (metadata.externalUrl) {
const providerName = metadata.provider || 'Link';
return `<a href="${metadata.externalUrl}" target="_blank">${providerName}</a>`;
}
throw new Error("No provider ID found in metadata.");
}

View File

@@ -85,7 +85,7 @@
width="200"
appendTo="body"
[preview]="true"
styleClass="square-cover">
class="square-cover">
</p-image>
</div>
</div>

View File

@@ -254,10 +254,10 @@
</a>
}
@if (book?.metadata?.comicvineId) {
@if (book?.metadata?.comicMetadata?.webLink) {
<a
class="rating-link"
[href]="'https://comicvine.gamespot.com/' + book.metadata!.comicvineId"
[href]="book.metadata!.comicMetadata!.webLink"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -10,7 +10,7 @@
<p-tag
[value]="getSyncStatusLabel()"
[severity]="getSyncStatusSeverity()"
styleClass="sync-status-tag"
class="sync-status-tag"
/>
</div>
</div>

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="ref.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="dynamicDialogRef.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -162,7 +162,7 @@
header="Merge/Split {{ currentMergeType | titlecase }}"
[(visible)]="showMergeDialog"
[modal]="true"
styleClass="user-dialog"
class="user-dialog"
[style]="{width: '50rem'}">
<div class="dialog-form">
<p>You are merging/splitting {{ getSelectedItems(currentMergeType).length }} {{ currentMergeType }}:</p>
@@ -238,7 +238,7 @@
header="Rename/Split {{ currentMergeType | titlecase }}"
[(visible)]="showRenameDialog"
[modal]="true"
styleClass="user-dialog"
class="user-dialog"
[style]="{width: '40rem'}">
<div class="dialog-form">
<p>You are renaming/splitting:</p>
@@ -308,7 +308,7 @@
header="Delete {{ currentMergeType | titlecase }}"
[(visible)]="showDeleteDialog"
[modal]="true"
styleClass="user-dialog"
class="user-dialog"
[style]="{width: '40rem'}">
<div class="dialog-form">
<div class="warning-message">

View File

@@ -36,28 +36,28 @@
<p-select [options]="providersWithClear" [(ngModel)]="bulkP1"
(ngModelChange)="setBulkProvider('p1', $event)"
placeholder="Set all P1" appendTo="body"
styleClass="select-full-width" size="small">
class="select-full-width" size="small">
</p-select>
</td>
<td class="table-cell">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP2"
(ngModelChange)="setBulkProvider('p2', $event)"
placeholder="Set all P2" appendTo="body"
styleClass="select-full-width" size="small">
class="select-full-width" size="small">
</p-select>
</td>
<td class="table-cell">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP3"
(ngModelChange)="setBulkProvider('p3', $event)"
placeholder="Set all P3" appendTo="body"
styleClass="select-full-width" size="small">
class="select-full-width" size="small">
</p-select>
</td>
<td class="table-cell">
<p-select [options]="providersWithClear" [(ngModel)]="bulkP4"
(ngModelChange)="setBulkProvider('p4', $event)"
placeholder="Set all P4" appendTo="body"
styleClass="select-full-width" size="small">
class="select-full-width" size="small">
</p-select>
</td>
</tr>
@@ -75,28 +75,28 @@
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p1"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
styleClass="select-full-width" size="small">
class="select-full-width" size="small">
</p-select>
</td>
<td class="table-cell-sm">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p2"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
styleClass="select-full-width" size="small">
class="select-full-width" size="small">
</p-select>
</td>
<td class="table-cell-sm">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p3"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
styleClass="select-full-width" size="small">
class="select-full-width" size="small">
</p-select>
</td>
<td class="table-cell-sm">
<p-select [options]="providers" [(ngModel)]="fieldOptions[field].p4"
[disabled]="!enabledFields[field]"
placeholder="Unset" appendTo="body"
styleClass="select-full-width" size="small">
class="select-full-width" size="small">
</p-select>
</td>
</tr>
@@ -125,7 +125,7 @@
<div class="option-item">
<p pTooltip="Controls how fetched metadata replaces existing values. 'Replace Missing Only' only fills empty fields, 'Replace All Fields' overwrites existing values." tooltipPosition="top">Replace Mode:</p>
<p-select [options]="replaceModeOptions" [(ngModel)]="replaceMode" optionLabel="label" optionValue="value"
appendTo="body" size="small" styleClass="select-mode">
appendTo="body" size="small" class="select-mode">
</p-select>
</div>
<div class="option-item">

View File

@@ -109,14 +109,21 @@
.options-group {
display: flex;
flex-direction: row;
gap: 2.5rem;
flex-wrap: wrap;
align-items: center;
gap: 1rem 2.5rem;
}
.option-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
gap: 0.5rem;
p {
margin: 0;
white-space: nowrap;
}
}
.action-buttons {

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="ref.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="dialogRef.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -104,7 +104,7 @@
[max]="duration || 100"
[step]="1"
(onChange)="seek($event)"
styleClass="progress-slider"
class="progress-slider"
/>
</div>
</div>
@@ -154,7 +154,7 @@
severity="contrast"
(onClick)="togglePlay()"
[disabled]="audioLoading"
styleClass="play-button"
class="play-button"
size="large"
/>
@@ -214,7 +214,7 @@
[max]="1"
[step]="0.01"
(onChange)="setVolume($event)"
styleClass="volume-slider"
class="volume-slider"
/>
</div>
@@ -252,7 +252,7 @@
[rounded]="true"
[text]="true"
severity="secondary"
[styleClass]="sleepTimerActive ? 'sleep-timer-active' : ''"
[class]="sleepTimerActive ? 'sleep-timer-active' : ''"
(onClick)="sleepTimerMenu.toggle($event)"
size="small"
/>

View File

@@ -64,11 +64,11 @@
</label>
@if (!fontsLoadedInBrowser) {
<div class="font-preview-skeleton">
<p-skeleton width="100%" height="1rem" styleClass="mb-3"></p-skeleton>
<p-skeleton width="100%" height="1rem" styleClass="mb-3"></p-skeleton>
<p-skeleton width="85%" height="1rem" styleClass="mb-3"></p-skeleton>
<p-skeleton width="60%" height="1rem" styleClass="mb-3"></p-skeleton>
<p-skeleton width="85%" height="1rem" styleClass="mb-3"></p-skeleton>
<p-skeleton width="100%" height="1rem" class="mb-3"></p-skeleton>
<p-skeleton width="100%" height="1rem" class="mb-3"></p-skeleton>
<p-skeleton width="85%" height="1rem" class="mb-3"></p-skeleton>
<p-skeleton width="60%" height="1rem" class="mb-3"></p-skeleton>
<p-skeleton width="85%" height="1rem" class="mb-3"></p-skeleton>
<p-skeleton width="95%" height="0.875rem"></p-skeleton>
</div>
} @else {

View File

@@ -39,11 +39,13 @@ export class LibraryMetadataSettingsComponent implements OnInit {
activePanel: number | null = null;
sidecarExporting: Record<number, boolean> = {};
sidecarImporting: Record<number, boolean> = {};
private cachedDefaultOptions: Record<number, MetadataRefreshOptions> = {};
ngOnInit() {
this.appSettingsService.appSettings$.subscribe(appSettings => {
if (appSettings) {
this.defaultMetadataOptions = appSettings.defaultMetadataRefreshOptions;
this.cachedDefaultOptions = {};
this.initializeLibraryOptions(appSettings);
this.updateLibraryOptionsFromSettings(appSettings);
}
@@ -80,7 +82,13 @@ export class LibraryMetadataSettingsComponent implements OnInit {
}
getLibraryOptions(libraryId: number): MetadataRefreshOptions {
return this.libraryMetadataOptions[libraryId] || {...this.defaultMetadataOptions, libraryId};
if (this.libraryMetadataOptions[libraryId]) {
return this.libraryMetadataOptions[libraryId];
}
if (!this.cachedDefaultOptions[libraryId]) {
this.cachedDefaultOptions[libraryId] = {...this.defaultMetadataOptions, libraryId};
}
return this.cachedDefaultOptions[libraryId];
}
trackByLibrary(index: number, library: Library): number | undefined {

View File

@@ -291,7 +291,7 @@
header="Create OPDS User"
[(visible)]="showCreateUserDialog"
[modal]="true"
styleClass="user-dialog"
class="user-dialog"
[style]="{width: '400px'}">
<div class="dialog-form">
<div class="form-field">

View File

@@ -424,7 +424,7 @@
header="Change Password"
[(visible)]="isPasswordDialogVisible"
[modal]="true"
styleClass="user-dialog"
class="user-dialog"
[style]="{width: '400px'}">
<div class="dialog-form">
<div class="form-field">

View File

@@ -193,7 +193,8 @@ export type VisibleFilterType =
| 'personalRating' | 'publisher' | 'matchScore' | 'library' | 'shelf'
| 'shelfStatus' | 'tag' | 'publishedDate' | 'fileSize' | 'amazonRating'
| 'goodreadsRating' | 'hardcoverRating' | 'language' | 'pageCount' | 'mood'
| 'ageRating' | 'contentRating';
| 'ageRating' | 'contentRating'
| 'comicCharacter' | 'comicTeam' | 'comicLocation' | 'comicCreator';
export const DEFAULT_VISIBLE_FILTERS: VisibleFilterType[] = [
'author', 'category', 'series', 'bookType', 'readStatus',
@@ -223,7 +224,11 @@ export const ALL_FILTER_OPTIONS: { label: string; value: VisibleFilterType }[] =
{label: 'Mood', value: 'mood'},
{label: 'Amazon Rating', value: 'amazonRating'},
{label: 'Goodreads Rating', value: 'goodreadsRating'},
{label: 'Hardcover Rating', value: 'hardcoverRating'}
{label: 'Hardcover Rating', value: 'hardcoverRating'},
{label: 'Comic Character', value: 'comicCharacter'},
{label: 'Comic Team', value: 'comicTeam'},
{label: 'Comic Location', value: 'comicLocation'},
{label: 'Comic Creator', value: 'comicCreator'}
];
export interface UserSettings {

View File

@@ -16,7 +16,7 @@
[rounded]="true"
severity="secondary"
(onClick)="closeDialog()"
styleClass="close-button">
class="close-button">
</p-button>
</div>
@@ -134,7 +134,7 @@
id="confirmNewPassword"
formControlName="confirmNewPassword"
[feedback]="false"
styleClass="field-input"
class="field-input"
[inputStyle]="{'width': '100%'}">
</p-password>
@if (changePasswordForm.get('confirmNewPassword')?.invalid &&

View File

@@ -31,26 +31,58 @@
<label class="setting-label">Visible Filters</label>
</div>
<p class="setting-description">
Select which filters appear in the filter sidebar. Choose between {{ minFilters }} and {{ maxFilters }} filters.
Choose which filters appear in the sidebar and drag to reorder. Between {{ minFilters }} and {{ maxFilters }} filters.
<span class="filter-count">({{ selectionCountText }})</span>
</p>
<div class="setting-options">
<p-multiselect
[options]="allFilterOptions"
[(ngModel)]="selectedVisibleFilters"
(ngModelChange)="onVisibleFiltersChange()"
<div class="filter-order-editor">
<div
#filterList
class="filter-order-list"
cdkDropList
[cdkDropListData]="selectedVisibleFilters"
(cdkDropListDropped)="onDrop($event)">
@for (f of selectedVisibleFilters; track f; let i = $index) {
<div class="filter-order-row" cdkDrag>
<div class="filter-drag-handle" cdkDragHandle>
<i class="pi pi-bars"></i>
</div>
<span class="filter-rank">{{ i + 1 }}.</span>
<span class="filter-label">{{ getFilterLabel(f) }}</span>
<button
type="button"
class="remove-filter-btn"
(click)="removeFilter(i)"
[disabled]="selectedVisibleFilters.length <= minFilters"
pTooltip="Remove"
tooltipPosition="top">
<i class="pi pi-times"></i>
</button>
</div>
}
</div>
<div class="filter-actions-row">
@if (availableFilters.length > 0 && selectedVisibleFilters.length < maxFilters) {
<p-select
size="small"
[options]="availableFilters"
[(ngModel)]="selectedAddFilter"
optionLabel="label"
optionValue="value"
[showClear]="false"
[filter]="true"
filterPlaceHolder="Search filters..."
placeholder="Select filters"
display="chip"
placeholder="Add filter..."
[style]="{ width: '200px' }"
appendTo="body"
[maxSelectedLabels]="5"
selectedItemsLabel="{0} filters selected"
styleClass="visible-filters-select">
</p-multiselect>
(onChange)="addFilter()">
</p-select>
}
<button
type="button"
class="reset-btn"
(click)="resetToDefaults()"
pTooltip="Reset to defaults"
tooltipPosition="top">
<i class="pi pi-refresh"></i>
</button>
</div>
</div>
</div>

View File

@@ -47,19 +47,150 @@
width: 100%;
}
}
p-multiselect {
min-width: 300px;
max-width: 100%;
@media (max-width: 768px) {
min-width: 100%;
width: 100%;
}
}
}
.filter-count {
font-weight: 500;
color: var(--p-primary-color);
}
// Filter order editor styles
.filter-order-editor {
margin-top: 0.75rem;
}
.filter-order-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 40px;
max-height: 320px;
max-width: 350px;
overflow-y: auto;
margin-bottom: 0.75rem;
}
.filter-order-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background-color: var(--overlay-background);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: move;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
&:hover {
background-color: var(--ground-background);
}
}
.cdk-drag-preview {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
background-color: var(--card-background);
border: 1px solid var(--primary-color);
border-radius: 6px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25);
}
.cdk-drag-placeholder {
opacity: 0.3;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.filter-order-list.cdk-drop-list-dragging .filter-order-row:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.filter-drag-handle {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary-color);
cursor: grab;
&:active {
cursor: grabbing;
}
i {
font-size: 0.85rem;
}
}
.filter-rank {
font-weight: 600;
color: var(--primary-color);
min-width: 1.75rem;
}
.filter-label {
flex: 1;
font-size: 0.875rem;
color: var(--text-color);
}
.remove-filter-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: none;
border-radius: 4px;
background-color: transparent;
color: var(--p-red-500);
cursor: pointer;
transition: background-color 0.15s ease;
i {
font-size: 0.85rem;
}
&:hover:not(:disabled) {
background-color: var(--ground-background);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.filter-actions-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.reset-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: none;
border-radius: 4px;
background-color: transparent;
color: var(--text-secondary-color);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
i {
font-size: 0.85rem;
}
&:hover {
background-color: var(--ground-background);
color: var(--primary-color);
}
}

View File

@@ -1,30 +1,25 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {Component, ElementRef, inject, OnDestroy, OnInit, viewChild} from '@angular/core';
import {Select} from 'primeng/select';
import {
ALL_FILTER_OPTIONS,
BookFilterMode,
DEFAULT_VISIBLE_FILTERS,
User,
UserService,
UserSettings,
UserState,
VisibleFilterType
} from '../../user-management/user.service';
import {ALL_FILTER_OPTIONS, BookFilterMode, DEFAULT_VISIBLE_FILTERS, User, UserService, UserSettings, UserState, VisibleFilterType} from '../../user-management/user.service';
import {MessageService} from 'primeng/api';
import {Observable, Subject} from 'rxjs';
import {FormsModule} from '@angular/forms';
import {filter, takeUntil} from 'rxjs/operators';
import {MultiSelect} from 'primeng/multiselect';
import {CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray} from '@angular/cdk/drag-drop';
import {Tooltip} from 'primeng/tooltip';
const MIN_VISIBLE_FILTERS = 5;
const MAX_VISIBLE_FILTERS = 15;
const MAX_VISIBLE_FILTERS = 20;
@Component({
selector: 'app-filter-preferences',
imports: [
Select,
FormsModule,
MultiSelect
CdkDropList,
CdkDrag,
CdkDragHandle,
Tooltip
],
templateUrl: './filter-preferences.component.html',
styleUrl: './filter-preferences.component.scss'
@@ -44,6 +39,8 @@ export class FilterPreferencesComponent implements OnInit, OnDestroy {
selectedFilterMode: BookFilterMode = 'and';
selectedVisibleFilters: VisibleFilterType[] = [...DEFAULT_VISIBLE_FILTERS];
private readonly filterList = viewChild<ElementRef<HTMLElement>>('filterList');
private readonly userService = inject(UserService);
private readonly messageService = inject(MessageService);
private readonly destroy$ = new Subject<void>();
@@ -95,25 +92,43 @@ export class FilterPreferencesComponent implements OnInit, OnDestroy {
this.updatePreference(['filterMode'], this.selectedFilterMode);
}
onVisibleFiltersChange(): void {
if (this.selectedVisibleFilters.length < MIN_VISIBLE_FILTERS) {
this.messageService.add({
severity: 'warn',
summary: 'Minimum Filters Required',
detail: `Please select at least ${MIN_VISIBLE_FILTERS} filters.`,
life: 2000
});
return;
selectedAddFilter: string | null = null;
get availableFilters(): {label: string; value: string}[] {
const used = new Set(this.selectedVisibleFilters);
return this.allFilterOptions.filter(opt => !used.has(opt.value));
}
if (this.selectedVisibleFilters.length > MAX_VISIBLE_FILTERS) {
this.messageService.add({
severity: 'warn',
summary: 'Maximum Filters Exceeded',
detail: `Please select at most ${MAX_VISIBLE_FILTERS} filters.`,
life: 2000
});
return;
getFilterLabel(value: string): string {
return this.allFilterOptions.find(opt => opt.value === value)?.label ?? value;
}
onDrop(event: CdkDragDrop<VisibleFilterType[]>): void {
moveItemInArray(this.selectedVisibleFilters, event.previousIndex, event.currentIndex);
this.updatePreference(['visibleFilters'], this.selectedVisibleFilters);
}
addFilter(): void {
if (this.selectedAddFilter) {
this.selectedVisibleFilters.push(this.selectedAddFilter as VisibleFilterType);
this.selectedAddFilter = null;
this.updatePreference(['visibleFilters'], this.selectedVisibleFilters);
requestAnimationFrame(() => {
const el = this.filterList()?.nativeElement;
if (el) el.scrollTop = el.scrollHeight;
});
}
}
removeFilter(index: number): void {
if (this.selectedVisibleFilters.length > MIN_VISIBLE_FILTERS) {
this.selectedVisibleFilters.splice(index, 1);
this.updatePreference(['visibleFilters'], this.selectedVisibleFilters);
}
}
resetToDefaults(): void {
this.selectedVisibleFilters = [...DEFAULT_VISIBLE_FILTERS];
this.updatePreference(['visibleFilters'], this.selectedVisibleFilters);
}

View File

@@ -18,7 +18,7 @@
[draggable]="false"
[resizable]="false"
[style]="{width: '600px', maxWidth: '90vw'}"
styleClass="config-dialog"
class="config-dialog"
header="Chart Configuration">
<div class="config-dialog-content">

View File

@@ -150,7 +150,7 @@
<p-progressbar
[value]="getOverallProgress()"
[showValue]="false"
styleClass="overall-progress-bar"/>
class="overall-progress-bar"/>
</div>
}
<div class="files-list">

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="ref.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>
@@ -104,7 +104,7 @@
optionValue="id"
placeholder="Select library"
(onChange)="onDefaultLibraryChange()"
styleClass="select-library"
class="select-library"
size="small"
appendTo="body"
></p-select>
@@ -116,7 +116,7 @@
optionValue="id"
placeholder="Select path"
(onChange)="onDefaultLibraryPathChange()"
styleClass="select-path"
class="select-path"
size="small"
appendTo="body"
></p-select>
@@ -175,7 +175,7 @@
placeholder="Select"
(onChange)="onLibraryChange(preview)"
[disabled]="preview.isMoved"
styleClass="select-full-width"
class="select-full-width"
appendTo="body"
size="small"
></p-select>
@@ -190,7 +190,7 @@
placeholder="Select"
(onChange)="onLibraryPathChange(preview)"
[disabled]="preview.isMoved"
styleClass="select-full-width"
class="select-full-width"
appendTo="body"
size="small"
></p-select>

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>
@@ -37,7 +37,7 @@
icon="pi pi-github"
outlined
severity="success"
styleClass="action-button">
class="action-button">
</p-button>
</a>
<a href="https://opencollective.com/booklore" target="_blank" rel="noopener noreferrer">
@@ -46,7 +46,7 @@
icon="pi pi-heart"
severity="info"
outlined
styleClass="action-button">
class="action-button">
</p-button>
</a>
</div>

View File

@@ -12,19 +12,19 @@
<p-tag
value="Review"
severity="warn"
styleClass="task-tag">
class="task-tag">
</p-tag>
} @else {
<p-tag
value="Auto"
severity="success"
styleClass="task-tag">
class="task-tag">
</p-tag>
}
<p-tag
[value]="getStatusLabel(task.value.status)"
[severity]="getTagSeverity(task.value.status)"
styleClass="task-tag"
class="task-tag"
></p-tag>
</div>
</div>

View File

@@ -13,7 +13,7 @@
[text]="true"
severity="secondary"
(click)="dialogRef.close()"
styleClass="close-button">
class="close-button">
</p-button>
</div>

View File

@@ -55,6 +55,72 @@ export const AUDIOBOOK_METADATA_FIELDS: MetadataFieldConfig[] = [
{label: 'Abridged', controlName: 'abridged', lockedKey: 'abridgedLocked', fetchedKey: 'abridged', type: 'boolean'}
];
// Comic book metadata fields - stored nested under BookMetadata.comicMetadata
export const COMIC_TEXT_METADATA_FIELDS: MetadataFieldConfig[] = [
{label: 'Issue #', controlName: 'comicIssueNumber', lockedKey: 'comicIssueNumberLocked', fetchedKey: 'issueNumber', type: 'string'},
{label: 'Volume', controlName: 'comicVolumeName', lockedKey: 'comicVolumeNameLocked', fetchedKey: 'volumeName', type: 'string'},
{label: 'Volume #', controlName: 'comicVolumeNumber', lockedKey: 'comicVolumeNumberLocked', fetchedKey: 'volumeNumber', type: 'number'},
{label: 'Story Arc', controlName: 'comicStoryArc', lockedKey: 'comicStoryArcLocked', fetchedKey: 'storyArc', type: 'string'},
{label: 'Arc #', controlName: 'comicStoryArcNumber', lockedKey: 'comicStoryArcNumberLocked', fetchedKey: 'storyArcNumber', type: 'number'},
{label: 'Alt. Series', controlName: 'comicAlternateSeries', lockedKey: 'comicAlternateSeriesLocked', fetchedKey: 'alternateSeries', type: 'string'},
{label: 'Alt. Issue', controlName: 'comicAlternateIssue', lockedKey: 'comicAlternateIssueLocked', fetchedKey: 'alternateIssue', type: 'string'},
{label: 'Imprint', controlName: 'comicImprint', lockedKey: 'comicImprintLocked', fetchedKey: 'imprint', type: 'string'},
{label: 'Format', controlName: 'comicFormat', lockedKey: 'comicFormatLocked', fetchedKey: 'format', type: 'string'},
{label: 'Reading Dir.', controlName: 'comicReadingDirection', lockedKey: 'comicReadingDirectionLocked', fetchedKey: 'readingDirection', type: 'string'},
{label: 'Web Link', controlName: 'comicWebLink', lockedKey: 'comicWebLinkLocked', fetchedKey: 'webLink', type: 'string'},
{label: 'B&W', controlName: 'comicBlackAndWhite', lockedKey: 'comicBlackAndWhiteLocked', fetchedKey: 'blackAndWhite', type: 'boolean'},
{label: 'Manga', controlName: 'comicManga', lockedKey: 'comicMangaLocked', fetchedKey: 'manga', type: 'boolean'},
];
export const COMIC_ARRAY_METADATA_FIELDS: MetadataFieldConfig[] = [
{label: 'Pencillers', controlName: 'comicPencillers', lockedKey: 'comicPencillersLocked', fetchedKey: 'pencillers', type: 'array'},
{label: 'Inkers', controlName: 'comicInkers', lockedKey: 'comicInkersLocked', fetchedKey: 'inkers', type: 'array'},
{label: 'Colorists', controlName: 'comicColorists', lockedKey: 'comicColoristsLocked', fetchedKey: 'colorists', type: 'array'},
{label: 'Letterers', controlName: 'comicLetterers', lockedKey: 'comicLetterersLocked', fetchedKey: 'letterers', type: 'array'},
{label: 'Cover Artists', controlName: 'comicCoverArtists', lockedKey: 'comicCoverArtistsLocked', fetchedKey: 'coverArtists', type: 'array'},
{label: 'Editors', controlName: 'comicEditors', lockedKey: 'comicEditorsLocked', fetchedKey: 'editors', type: 'array'},
{label: 'Characters', controlName: 'comicCharacters', lockedKey: 'comicCharactersLocked', fetchedKey: 'characters', type: 'array'},
{label: 'Teams', controlName: 'comicTeams', lockedKey: 'comicTeamsLocked', fetchedKey: 'teams', type: 'array'},
{label: 'Locations', controlName: 'comicLocations', lockedKey: 'comicLocationsLocked', fetchedKey: 'locations', type: 'array'},
];
export const COMIC_TEXTAREA_METADATA_FIELDS: MetadataFieldConfig[] = [
{label: 'Notes', controlName: 'comicNotes', lockedKey: 'comicNotesLocked', fetchedKey: 'notes', type: 'textarea'},
];
export const ALL_COMIC_METADATA_FIELDS: MetadataFieldConfig[] = [
...COMIC_TEXT_METADATA_FIELDS,
...COMIC_ARRAY_METADATA_FIELDS,
...COMIC_TEXTAREA_METADATA_FIELDS,
];
// Maps form lockedKey → ComicMetadata lock property name (1:1 per-field locks).
export const COMIC_FORM_TO_MODEL_LOCK: Record<string, string> = {
'comicIssueNumberLocked': 'issueNumberLocked',
'comicVolumeNameLocked': 'volumeNameLocked',
'comicVolumeNumberLocked': 'volumeNumberLocked',
'comicStoryArcLocked': 'storyArcLocked',
'comicStoryArcNumberLocked': 'storyArcNumberLocked',
'comicAlternateSeriesLocked': 'alternateSeriesLocked',
'comicAlternateIssueLocked': 'alternateIssueLocked',
'comicImprintLocked': 'imprintLocked',
'comicFormatLocked': 'formatLocked',
'comicBlackAndWhiteLocked': 'blackAndWhiteLocked',
'comicMangaLocked': 'mangaLocked',
'comicReadingDirectionLocked': 'readingDirectionLocked',
'comicWebLinkLocked': 'webLinkLocked',
'comicNotesLocked': 'notesLocked',
'comicPencillersLocked': 'pencillersLocked',
'comicInkersLocked': 'inkersLocked',
'comicColoristsLocked': 'coloristsLocked',
'comicLetterersLocked': 'letterersLocked',
'comicCoverArtistsLocked': 'coverArtistsLocked',
'comicEditorsLocked': 'editorsLocked',
'comicCharactersLocked': 'charactersLocked',
'comicTeamsLocked': 'teamsLocked',
'comicLocationsLocked': 'locationsLocked',
};
export const TOP_FIELD_NAMES = ['title', 'subtitle', 'publisher', 'publishedDate'];
export const ARRAY_FIELD_NAMES = ['authors', 'categories', 'moods', 'tags'];
export const TEXTAREA_FIELD_NAMES = ['description'];

View File

@@ -1,6 +1,6 @@
import {Injectable} from '@angular/core';
import {FormControl, FormGroup} from '@angular/forms';
import {ALL_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, MetadataFieldConfig} from './metadata-field.config';
import {ALL_COMIC_METADATA_FIELDS, ALL_METADATA_FIELDS, AUDIOBOOK_METADATA_FIELDS, MetadataFieldConfig} from './metadata-field.config';
@Injectable({
providedIn: 'root'
@@ -31,6 +31,15 @@ export class MetadataFormBuilder {
}
}
// Add comic book metadata fields
for (const field of ALL_COMIC_METADATA_FIELDS) {
const defaultValue = this.getDefaultValue(field.type);
controls[field.controlName] = new FormControl(defaultValue);
if (includeLockedControls) {
controls[field.lockedKey] = new FormControl(false);
}
}
controls['thumbnailUrl'] = new FormControl('');
controls['audiobookThumbnailUrl'] = new FormControl('');
if (includeLockedControls) {
@@ -78,6 +87,13 @@ export class MetadataFormBuilder {
metadataForm.get(field.controlName)?.disable({emitEvent: false});
}
}
// Apply locks for comic book metadata fields
for (const field of ALL_COMIC_METADATA_FIELDS) {
if (lockedFields[field.lockedKey]) {
metadataForm.get(field.controlName)?.disable({emitEvent: false});
}
}
}
setAllFieldsLocked(metadataForm: FormGroup, locked: boolean): void {