feat(comic-metadata): add extended comic metadata support (#2654)

* feat(metadata): add comic metadata support

* Fix metadata saving

* feat(comic-metadata): normalize comic metadata with relational tables

* feat(comic-metadata): add comic metadata UI components and model

---------

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2026-02-07 21:15:59 -07:00
committed by GitHub
parent b2a4aa7960
commit c1c72ea7ba
32 changed files with 2292 additions and 191 deletions

View File

@@ -15,7 +15,7 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Mapper(componentModel = "spring", uses = {BookMetadataMapper.class, ShelfMapper.class, AdditionalFileMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
@Mapper(componentModel = "spring", uses = {BookMetadataMapper.class, ShelfMapper.class, AdditionalFileMapper.class, ComicMetadataMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface BookMapper {
@Mapping(source = "library.id", target = "libraryId")

View File

@@ -4,7 +4,7 @@ import org.booklore.model.dto.BookMetadata;
import org.booklore.model.entity.BookMetadataEntity;
import org.mapstruct.*;
@Mapper(componentModel = "spring", uses = {AuthorMapper.class, CategoryMapper.class, MoodMapper.class, TagMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
@Mapper(componentModel = "spring", uses = {AuthorMapper.class, CategoryMapper.class, MoodMapper.class, TagMapper.class, ComicMetadataMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface BookMetadataMapper {
@AfterMapping

View File

@@ -0,0 +1,84 @@
package org.booklore.mapper;
import org.booklore.model.dto.ComicMetadata;
import org.booklore.model.entity.*;
import org.booklore.model.enums.ComicCreatorRole;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Named;
import org.mapstruct.ReportingPolicy;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface ComicMetadataMapper {
@Mapping(target = "characters", source = "characters", qualifiedByName = "charactersToStrings")
@Mapping(target = "teams", source = "teams", qualifiedByName = "teamsToStrings")
@Mapping(target = "locations", source = "locations", qualifiedByName = "locationsToStrings")
@Mapping(target = "pencillers", source = "creatorMappings", qualifiedByName = "pencillersToStrings")
@Mapping(target = "inkers", source = "creatorMappings", qualifiedByName = "inkersToStrings")
@Mapping(target = "colorists", source = "creatorMappings", qualifiedByName = "coloristsToStrings")
@Mapping(target = "letterers", source = "creatorMappings", qualifiedByName = "letterersToStrings")
@Mapping(target = "coverArtists", source = "creatorMappings", qualifiedByName = "coverArtistsToStrings")
@Mapping(target = "editors", source = "creatorMappings", qualifiedByName = "editorsToStrings")
ComicMetadata toComicMetadata(ComicMetadataEntity entity);
@Named("charactersToStrings")
default Set<String> charactersToStrings(Set<ComicCharacterEntity> characters) {
if (characters == null) return Collections.emptySet();
return characters.stream().map(ComicCharacterEntity::getName).collect(Collectors.toSet());
}
@Named("teamsToStrings")
default Set<String> teamsToStrings(Set<ComicTeamEntity> teams) {
if (teams == null) return Collections.emptySet();
return teams.stream().map(ComicTeamEntity::getName).collect(Collectors.toSet());
}
@Named("locationsToStrings")
default Set<String> locationsToStrings(Set<ComicLocationEntity> locations) {
if (locations == null) return Collections.emptySet();
return locations.stream().map(ComicLocationEntity::getName).collect(Collectors.toSet());
}
@Named("pencillersToStrings")
default Set<String> pencillersToStrings(Set<ComicCreatorMappingEntity> mappings) {
return creatorsToStringsByRole(mappings, ComicCreatorRole.PENCILLER);
}
@Named("inkersToStrings")
default Set<String> inkersToStrings(Set<ComicCreatorMappingEntity> mappings) {
return creatorsToStringsByRole(mappings, ComicCreatorRole.INKER);
}
@Named("coloristsToStrings")
default Set<String> coloristsToStrings(Set<ComicCreatorMappingEntity> mappings) {
return creatorsToStringsByRole(mappings, ComicCreatorRole.COLORIST);
}
@Named("letterersToStrings")
default Set<String> letterersToStrings(Set<ComicCreatorMappingEntity> mappings) {
return creatorsToStringsByRole(mappings, ComicCreatorRole.LETTERER);
}
@Named("coverArtistsToStrings")
default Set<String> coverArtistsToStrings(Set<ComicCreatorMappingEntity> mappings) {
return creatorsToStringsByRole(mappings, ComicCreatorRole.COVER_ARTIST);
}
@Named("editorsToStrings")
default Set<String> editorsToStrings(Set<ComicCreatorMappingEntity> mappings) {
return creatorsToStringsByRole(mappings, ComicCreatorRole.EDITOR);
}
default Set<String> creatorsToStringsByRole(Set<ComicCreatorMappingEntity> mappings, ComicCreatorRole role) {
if (mappings == null) return Collections.emptySet();
return mappings.stream()
.filter(m -> m.getRole() == role)
.map(m -> m.getCreator().getName())
.collect(Collectors.toSet());
}
}

View File

@@ -1,5 +1,6 @@
package org.booklore.mapper.v2;
import org.booklore.mapper.ComicMetadataMapper;
import org.booklore.mapper.ShelfMapper;
import org.booklore.model.dto.*;
import org.booklore.model.entity.*;
@@ -15,7 +16,7 @@ import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Mapper(componentModel = "spring", uses = ShelfMapper.class, unmappedTargetPolicy = ReportingPolicy.IGNORE)
@Mapper(componentModel = "spring", uses = {ShelfMapper.class, ComicMetadataMapper.class}, unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface BookMapperV2 {
@Mapping(source = "library.id", target = "libraryId")

View File

@@ -33,6 +33,7 @@ public class BookMetadata {
private Boolean abridged;
private AudiobookMetadata audiobookMetadata;
private ComicMetadata comicMetadata;
private String asin;
private Double amazonRating;

View File

@@ -0,0 +1,52 @@
package org.booklore.model.dto;
import lombok.*;
import java.util.Set;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ComicMetadata {
private String issueNumber;
private String volumeName;
private Integer volumeNumber;
private String storyArc;
private Integer storyArcNumber;
private String alternateSeries;
private String alternateIssue;
// Creators
private Set<String> pencillers;
private Set<String> inkers;
private Set<String> colorists;
private Set<String> letterers;
private Set<String> coverArtists;
private Set<String> editors;
private String imprint;
private String format;
private Boolean blackAndWhite;
private Boolean manga;
private String readingDirection;
// Characters, teams, locations
private Set<String> characters;
private Set<String> teams;
private Set<String> locations;
private String webLink;
private String notes;
// Locked fields
private Boolean issueNumberLocked;
private Boolean volumeNameLocked;
private Boolean volumeNumberLocked;
private Boolean storyArcLocked;
private Boolean creatorsLocked;
private Boolean charactersLocked;
private Boolean teamsLocked;
private Boolean locationsLocked;
}

View File

@@ -1,10 +1,10 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.booklore.convertor.BookRecommendationIdsListConverter;
import org.booklore.model.dto.BookRecommendationLite;
import org.booklore.model.enums.BookFileType;
import jakarta.persistence.*;
import lombok.*;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -12,7 +12,6 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Entity
@Getter
@@ -96,15 +95,6 @@ public class BookEntity {
return Paths.get(libraryPath.getPath(), primaryBookFile.getFileSubPath(), primaryBookFile.getFileName());
}
public List<Path> getFullFilePaths() {
if (libraryPath == null || libraryPath.getPath() == null || bookFiles == null || bookFiles.isEmpty()) {
return List.of();
}
return bookFiles.stream()
.map(bookFile -> Paths.get(libraryPath.getPath(), bookFile.getFileSubPath(), bookFile.getFileName()))
.collect(Collectors.toList());
}
public BookFileEntity getPrimaryBookFile() {
if (bookFiles == null) {
bookFiles = new ArrayList<>();

View File

@@ -331,6 +331,10 @@ public class BookMetadataEntity {
@JsonIgnore
private BookEntity book;
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", referencedColumnName = "book_id", insertable = false, updatable = false)
private ComicMetadataEntity comicMetadata;
@ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "book_metadata_author_mapping",

View File

@@ -0,0 +1,21 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "comic_character")
public class ComicCharacterEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, unique = true)
private String name;
}

View File

@@ -0,0 +1,21 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "comic_creator")
public class ComicCreatorEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, unique = true)
private String name;
}

View File

@@ -0,0 +1,31 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.booklore.model.enums.ComicCreatorRole;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "comic_metadata_creator_mapping")
public class ComicCreatorMappingEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", nullable = false)
private ComicMetadataEntity comicMetadata;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "creator_id", nullable = false)
private ComicCreatorEntity creator;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false, length = 20)
private ComicCreatorRole role;
}

View File

@@ -0,0 +1,21 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "comic_location")
public class ComicLocationEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, unique = true)
private String name;
}

View File

@@ -0,0 +1,162 @@
package org.booklore.model.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Fetch;
import org.hibernate.annotations.FetchMode;
import java.util.HashSet;
import java.util.Set;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "comic_metadata")
public class ComicMetadataEntity {
@Id
@Column(name = "book_id")
private Long bookId;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "book_id", insertable = false, updatable = false)
@JsonIgnore
private BookMetadataEntity bookMetadata;
@Column(name = "issue_number")
private String issueNumber;
@Column(name = "volume_name")
private String volumeName;
@Column(name = "volume_number")
private Integer volumeNumber;
@Column(name = "story_arc")
private String storyArc;
@Column(name = "story_arc_number")
private Integer storyArcNumber;
@Column(name = "alternate_series")
private String alternateSeries;
@Column(name = "alternate_issue")
private String alternateIssue;
@Column(name = "imprint")
private String imprint;
@Column(name = "format", length = 50)
private String format;
@Column(name = "black_and_white")
@Builder.Default
private Boolean blackAndWhite = Boolean.FALSE;
@Column(name = "manga")
@Builder.Default
private Boolean manga = Boolean.FALSE;
@Column(name = "reading_direction", length = 10)
@Builder.Default
private String readingDirection = "ltr";
@Column(name = "web_link")
private String webLink;
@Column(name = "notes", columnDefinition = "TEXT")
private String notes;
// Many-to-many relationships
@ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "comic_metadata_character_mapping",
joinColumns = @JoinColumn(name = "book_id"),
inverseJoinColumns = @JoinColumn(name = "character_id"))
@Fetch(FetchMode.SUBSELECT)
@Builder.Default
private Set<ComicCharacterEntity> characters = new HashSet<>();
@ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "comic_metadata_team_mapping",
joinColumns = @JoinColumn(name = "book_id"),
inverseJoinColumns = @JoinColumn(name = "team_id"))
@Fetch(FetchMode.SUBSELECT)
@Builder.Default
private Set<ComicTeamEntity> teams = new HashSet<>();
@ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "comic_metadata_location_mapping",
joinColumns = @JoinColumn(name = "book_id"),
inverseJoinColumns = @JoinColumn(name = "location_id"))
@Fetch(FetchMode.SUBSELECT)
@Builder.Default
private Set<ComicLocationEntity> locations = new HashSet<>();
@OneToMany(mappedBy = "comicMetadata", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
@Builder.Default
private Set<ComicCreatorMappingEntity> creatorMappings = new HashSet<>();
// Locked fields
@Column(name = "issue_number_locked")
@Builder.Default
private Boolean issueNumberLocked = Boolean.FALSE;
@Column(name = "volume_name_locked")
@Builder.Default
private Boolean volumeNameLocked = Boolean.FALSE;
@Column(name = "volume_number_locked")
@Builder.Default
private Boolean volumeNumberLocked = Boolean.FALSE;
@Column(name = "story_arc_locked")
@Builder.Default
private Boolean storyArcLocked = Boolean.FALSE;
@Column(name = "creators_locked")
@Builder.Default
private Boolean creatorsLocked = Boolean.FALSE;
@Column(name = "characters_locked")
@Builder.Default
private Boolean charactersLocked = Boolean.FALSE;
@Column(name = "teams_locked")
@Builder.Default
private Boolean teamsLocked = Boolean.FALSE;
@Column(name = "locations_locked")
@Builder.Default
private Boolean locationsLocked = Boolean.FALSE;
public void applyLockToAllFields(boolean lock) {
this.issueNumberLocked = lock;
this.volumeNameLocked = lock;
this.volumeNumberLocked = lock;
this.storyArcLocked = lock;
this.creatorsLocked = lock;
this.charactersLocked = lock;
this.teamsLocked = lock;
this.locationsLocked = lock;
}
public boolean areAllFieldsLocked() {
return Boolean.TRUE.equals(this.issueNumberLocked)
&& Boolean.TRUE.equals(this.volumeNameLocked)
&& Boolean.TRUE.equals(this.volumeNumberLocked)
&& Boolean.TRUE.equals(this.storyArcLocked)
&& Boolean.TRUE.equals(this.creatorsLocked)
&& Boolean.TRUE.equals(this.charactersLocked)
&& Boolean.TRUE.equals(this.teamsLocked)
&& Boolean.TRUE.equals(this.locationsLocked);
}
}

View File

@@ -0,0 +1,21 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "comic_team")
public class ComicTeamEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, unique = true)
private String name;
}

View File

@@ -0,0 +1,10 @@
package org.booklore.model.enums;
public enum ComicCreatorRole {
PENCILLER,
INKER,
COLORIST,
LETTERER,
COVER_ARTIST,
EDITOR
}

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", "bookFiles" })
@EntityGraph(attributePaths = { "metadata", "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);

View File

@@ -0,0 +1,11 @@
package org.booklore.repository;
import org.booklore.model.entity.ComicCharacterEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ComicCharacterRepository extends JpaRepository<ComicCharacterEntity, Long> {
Optional<ComicCharacterEntity> findByName(String name);
}

View File

@@ -0,0 +1,16 @@
package org.booklore.repository;
import org.booklore.model.entity.ComicCreatorMappingEntity;
import org.booklore.model.enums.ComicCreatorRole;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ComicCreatorMappingRepository extends JpaRepository<ComicCreatorMappingEntity, Long> {
List<ComicCreatorMappingEntity> findByComicMetadataBookId(Long bookId);
List<ComicCreatorMappingEntity> findByComicMetadataBookIdAndRole(Long bookId, ComicCreatorRole role);
void deleteByComicMetadataBookId(Long bookId);
}

View File

@@ -0,0 +1,11 @@
package org.booklore.repository;
import org.booklore.model.entity.ComicCreatorEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ComicCreatorRepository extends JpaRepository<ComicCreatorEntity, Long> {
Optional<ComicCreatorEntity> findByName(String name);
}

View File

@@ -0,0 +1,11 @@
package org.booklore.repository;
import org.booklore.model.entity.ComicLocationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ComicLocationRepository extends JpaRepository<ComicLocationEntity, Long> {
Optional<ComicLocationEntity> findByName(String name);
}

View File

@@ -0,0 +1,21 @@
package org.booklore.repository;
import org.booklore.model.entity.ComicMetadataEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface ComicMetadataRepository extends JpaRepository<ComicMetadataEntity, Long> {
@Query("SELECT c FROM ComicMetadataEntity c WHERE c.bookId IN :bookIds")
List<ComicMetadataEntity> findAllByBookIds(@Param("bookIds") List<Long> bookIds);
List<ComicMetadataEntity> findAllByStoryArcIgnoreCase(String storyArc);
List<ComicMetadataEntity> findAllByVolumeNameIgnoreCase(String volumeName);
@Query("SELECT DISTINCT c FROM ComicMetadataEntity c JOIN c.creatorMappings m WHERE m.creator.name LIKE %:name%")
List<ComicMetadataEntity> findAllByCreatorName(@Param("name") String name);
}

View File

@@ -0,0 +1,11 @@
package org.booklore.repository;
import org.booklore.model.entity.ComicTeamEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ComicTeamRepository extends JpaRepository<ComicTeamEntity, Long> {
Optional<ComicTeamEntity> findByName(String name);
}

View File

@@ -1,21 +1,22 @@
package org.booklore.service.book;
import org.booklore.model.dto.ComicMetadata;
import org.booklore.model.dto.settings.LibraryFile;
import org.booklore.model.entity.*;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.enums.ComicCreatorRole;
import org.booklore.repository.*;
import org.booklore.service.file.FileFingerprint;
import org.booklore.util.FileUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Service
@AllArgsConstructor
public class BookCreatorService {
private final AuthorRepository authorRepository;
@@ -24,6 +25,38 @@ public class BookCreatorService {
private final TagRepository tagRepository;
private final BookRepository bookRepository;
private final BookMetadataRepository bookMetadataRepository;
private final ComicMetadataRepository comicMetadataRepository;
private final ComicCharacterRepository comicCharacterRepository;
private final ComicTeamRepository comicTeamRepository;
private final ComicLocationRepository comicLocationRepository;
private final ComicCreatorRepository comicCreatorRepository;
// Temporary storage for comic metadata DTOs during processing
private final Map<Long, ComicMetadata> pendingComicMetadata = new ConcurrentHashMap<>();
public BookCreatorService(AuthorRepository authorRepository,
CategoryRepository categoryRepository,
MoodRepository moodRepository,
TagRepository tagRepository,
BookRepository bookRepository,
BookMetadataRepository bookMetadataRepository,
ComicMetadataRepository comicMetadataRepository,
ComicCharacterRepository comicCharacterRepository,
ComicTeamRepository comicTeamRepository,
ComicLocationRepository comicLocationRepository,
ComicCreatorRepository comicCreatorRepository) {
this.authorRepository = authorRepository;
this.categoryRepository = categoryRepository;
this.moodRepository = moodRepository;
this.tagRepository = tagRepository;
this.bookRepository = bookRepository;
this.bookMetadataRepository = bookMetadataRepository;
this.comicMetadataRepository = comicMetadataRepository;
this.comicCharacterRepository = comicCharacterRepository;
this.comicTeamRepository = comicTeamRepository;
this.comicLocationRepository = comicLocationRepository;
this.comicCreatorRepository = comicCreatorRepository;
}
public BookEntity createShellBook(LibraryFile libraryFile, BookFileType bookFileType) {
Optional<BookEntity> existingBookOpt = bookRepository.findByLibraryIdAndLibraryPathIdAndFileSubPathAndFileName(
@@ -149,5 +182,93 @@ public class BookCreatorService {
}
bookRepository.save(bookEntity);
bookMetadataRepository.save(bookEntity.getMetadata());
// Save comic metadata if present
ComicMetadataEntity comicMetadata = bookEntity.getMetadata().getComicMetadata();
if (comicMetadata != null && comicMetadata.getBookId() != null) {
comicMetadataRepository.save(comicMetadata);
// Populate relationships from pending DTO
ComicMetadata comicDto = pendingComicMetadata.remove(bookEntity.getId());
if (comicDto != null) {
populateComicMetadataRelationships(comicMetadata, comicDto);
}
}
}
public void setComicMetadataDto(BookEntity bookEntity, ComicMetadata comicDto) {
if (bookEntity.getId() != null && comicDto != null) {
pendingComicMetadata.put(bookEntity.getId(), comicDto);
}
}
private void populateComicMetadataRelationships(ComicMetadataEntity comic, ComicMetadata dto) {
// Add characters
if (dto.getCharacters() != null && !dto.getCharacters().isEmpty()) {
if (comic.getCharacters() == null) {
comic.setCharacters(new HashSet<>());
}
dto.getCharacters().stream()
.map(name -> truncate(name, 255))
.map(name -> comicCharacterRepository.findByName(name)
.orElseGet(() -> comicCharacterRepository.save(ComicCharacterEntity.builder().name(name).build())))
.forEach(entity -> comic.getCharacters().add(entity));
}
// Add teams
if (dto.getTeams() != null && !dto.getTeams().isEmpty()) {
if (comic.getTeams() == null) {
comic.setTeams(new HashSet<>());
}
dto.getTeams().stream()
.map(name -> truncate(name, 255))
.map(name -> comicTeamRepository.findByName(name)
.orElseGet(() -> comicTeamRepository.save(ComicTeamEntity.builder().name(name).build())))
.forEach(entity -> comic.getTeams().add(entity));
}
// Add locations
if (dto.getLocations() != null && !dto.getLocations().isEmpty()) {
if (comic.getLocations() == null) {
comic.setLocations(new HashSet<>());
}
dto.getLocations().stream()
.map(name -> truncate(name, 255))
.map(name -> comicLocationRepository.findByName(name)
.orElseGet(() -> comicLocationRepository.save(ComicLocationEntity.builder().name(name).build())))
.forEach(entity -> comic.getLocations().add(entity));
}
// Add creators with roles
if (comic.getCreatorMappings() == null) {
comic.setCreatorMappings(new HashSet<>());
}
addCreatorsWithRole(comic, dto.getPencillers(), ComicCreatorRole.PENCILLER);
addCreatorsWithRole(comic, dto.getInkers(), ComicCreatorRole.INKER);
addCreatorsWithRole(comic, dto.getColorists(), ComicCreatorRole.COLORIST);
addCreatorsWithRole(comic, dto.getLetterers(), ComicCreatorRole.LETTERER);
addCreatorsWithRole(comic, dto.getCoverArtists(), ComicCreatorRole.COVER_ARTIST);
addCreatorsWithRole(comic, dto.getEditors(), ComicCreatorRole.EDITOR);
// Save the updated comic metadata with relationships
comicMetadataRepository.save(comic);
}
private void addCreatorsWithRole(ComicMetadataEntity comic, Set<String> names, ComicCreatorRole role) {
if (names == null || names.isEmpty()) {
return;
}
for (String name : names) {
String truncatedName = truncate(name, 255);
ComicCreatorEntity creator = comicCreatorRepository.findByName(truncatedName)
.orElseGet(() -> comicCreatorRepository.save(ComicCreatorEntity.builder().name(truncatedName).build()));
ComicCreatorMappingEntity mapping = ComicCreatorMappingEntity.builder()
.comicMetadata(comic)
.creator(creator)
.role(role)
.build();
comic.getCreatorMappings().add(mapping);
}
}
}

View File

@@ -2,10 +2,12 @@ package org.booklore.service.fileprocessor;
import org.booklore.mapper.BookMapper;
import org.booklore.model.dto.BookMetadata;
import org.booklore.model.dto.ComicMetadata;
import org.booklore.model.dto.settings.LibraryFile;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookFileEntity;
import org.booklore.model.entity.BookMetadataEntity;
import org.booklore.model.entity.ComicMetadataEntity;
import org.booklore.model.enums.BookFileType;
import org.booklore.repository.BookAdditionalFileRepository;
import org.booklore.repository.BookRepository;
@@ -236,6 +238,9 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
if (extracted.getCategories() != null) {
bookCreatorService.addCategoriesToBook(extracted.getCategories(), bookEntity);
}
if (extracted.getComicMetadata() != null) {
saveComicMetadata(bookEntity, extracted.getComicMetadata());
}
} catch (Exception e) {
log.warn("Failed to extract ComicInfo metadata for '{}': {}", bookEntity.getPrimaryBookFile().getFileName(), e.getMessage());
// Fallback to filename-derived title
@@ -252,5 +257,38 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
String title = UNDERSCORE_HYPHEN_PATTERN.matcher(baseName).replaceAll(" ").trim();
bookEntity.getMetadata().setTitle(truncate(title, 1000));
}
private void saveComicMetadata(BookEntity bookEntity, ComicMetadata comicDto) {
Long bookId = bookEntity.getId();
if (bookId == null) {
log.warn("Cannot save comic metadata - book ID is null for '{}'",
bookEntity.getPrimaryBookFile().getFileName());
return;
}
ComicMetadataEntity comic = new ComicMetadataEntity();
comic.setBookId(bookId);
comic.setBookMetadata(bookEntity.getMetadata());
comic.setIssueNumber(comicDto.getIssueNumber());
comic.setVolumeName(comicDto.getVolumeName());
comic.setVolumeNumber(comicDto.getVolumeNumber());
comic.setStoryArc(comicDto.getStoryArc());
comic.setStoryArcNumber(comicDto.getStoryArcNumber());
comic.setAlternateSeries(comicDto.getAlternateSeries());
comic.setAlternateIssue(comicDto.getAlternateIssue());
comic.setImprint(comicDto.getImprint());
comic.setFormat(comicDto.getFormat());
comic.setBlackAndWhite(comicDto.getBlackAndWhite() != null ? comicDto.getBlackAndWhite() : Boolean.FALSE);
comic.setManga(comicDto.getManga() != null ? comicDto.getManga() : Boolean.FALSE);
comic.setReadingDirection(comicDto.getReadingDirection() != null ? comicDto.getReadingDirection() : "ltr");
comic.setWebLink(comicDto.getWebLink());
comic.setNotes(comicDto.getNotes());
// Set on parent - relationships will be populated in saveConnections()
bookEntity.getMetadata().setComicMetadata(comic);
// Store the DTO for later processing in saveConnections
bookCreatorService.setComicMetadataDto(bookEntity, comicDto);
}
}

View File

@@ -7,10 +7,12 @@ import org.booklore.model.MetadataClearFlags;
import org.booklore.model.MetadataUpdateContext;
import org.booklore.model.MetadataUpdateWrapper;
import org.booklore.model.dto.BookMetadata;
import org.booklore.model.dto.ComicMetadata;
import org.booklore.model.dto.FileMoveResult;
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
import org.booklore.model.entity.*;
import org.booklore.model.enums.BookFileType;
import org.booklore.model.enums.ComicCreatorRole;
import org.booklore.model.enums.MetadataReplaceMode;
import org.booklore.repository.*;
import org.booklore.service.appsettings.AppSettingService;
@@ -43,6 +45,11 @@ public class BookMetadataUpdater {
private final MoodRepository moodRepository;
private final TagRepository tagRepository;
private final BookRepository bookRepository;
private final ComicMetadataRepository comicMetadataRepository;
private final ComicCharacterRepository comicCharacterRepository;
private final ComicTeamRepository comicTeamRepository;
private final ComicLocationRepository comicLocationRepository;
private final ComicCreatorRepository comicCreatorRepository;
private final FileService fileService;
private final MetadataMatchService metadataMatchService;
private final AppSettingService appSettingService;
@@ -99,6 +106,7 @@ public class BookMetadataUpdater {
bookReviewUpdateService.updateBookReviews(newMetadata, metadata, clearFlags, mergeCategories);
updateThumbnailIfNeeded(bookId, bookEntity, newMetadata, metadata, updateThumbnail);
updateAudiobookMetadataIfNeeded(bookEntity, newMetadata, metadata, clearFlags, replaceMode);
updateComicMetadataIfNeeded(newMetadata, metadata, replaceMode);
updateLocks(newMetadata, metadata);
bookEntity.setMetadataUpdatedAt(Instant.now());
@@ -362,6 +370,183 @@ public class BookMetadataUpdater {
handleFieldUpdate(e.getAbridgedLocked(), clear.isAbridged(), m.getAbridged(), e::setAbridged, e::getAbridged, replaceMode);
}
private void updateComicMetadataIfNeeded(BookMetadata m, BookMetadataEntity e, MetadataReplaceMode replaceMode) {
ComicMetadata comicDto = m.getComicMetadata();
if (comicDto == null) {
return;
}
ComicMetadataEntity comic = e.getComicMetadata();
if (comic == null) {
comic = ComicMetadataEntity.builder()
.bookId(e.getBookId())
.bookMetadata(e)
.build();
e.setComicMetadata(comic);
}
ComicMetadataEntity c = comic;
// Update basic fields
handleFieldUpdate(c.getIssueNumberLocked(), false, comicDto.getIssueNumber(), v -> c.setIssueNumber(nullIfBlank(v)), c::getIssueNumber, replaceMode);
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);
// Update relationships if not locked
if (!Boolean.TRUE.equals(c.getCharactersLocked())) {
updateComicCharacters(c, comicDto.getCharacters(), replaceMode);
}
if (!Boolean.TRUE.equals(c.getTeamsLocked())) {
updateComicTeams(c, comicDto.getTeams(), replaceMode);
}
if (!Boolean.TRUE.equals(c.getLocationsLocked())) {
updateComicLocations(c, comicDto.getLocations(), replaceMode);
}
if (!Boolean.TRUE.equals(c.getCreatorsLocked())) {
updateComicCreators(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.getCreatorsLocked() != null) c.setCreatorsLocked(comicDto.getCreatorsLocked());
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);
}
private void updateComicCharacters(ComicMetadataEntity c, Set<String> characters, MetadataReplaceMode mode) {
if (characters == null || characters.isEmpty()) {
if (mode == MetadataReplaceMode.REPLACE_ALL) {
c.getCharacters().clear();
}
return;
}
if (c.getCharacters() == null) {
c.setCharacters(new HashSet<>());
}
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
c.getCharacters().clear();
}
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getCharacters().isEmpty()) {
return;
}
characters.stream()
.map(name -> comicCharacterRepository.findByName(name)
.orElseGet(() -> comicCharacterRepository.save(ComicCharacterEntity.builder().name(name).build())))
.forEach(entity -> c.getCharacters().add(entity));
}
private void updateComicTeams(ComicMetadataEntity c, Set<String> teams, MetadataReplaceMode mode) {
if (teams == null || teams.isEmpty()) {
if (mode == MetadataReplaceMode.REPLACE_ALL) {
c.getTeams().clear();
}
return;
}
if (c.getTeams() == null) {
c.setTeams(new HashSet<>());
}
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
c.getTeams().clear();
}
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getTeams().isEmpty()) {
return;
}
teams.stream()
.map(name -> comicTeamRepository.findByName(name)
.orElseGet(() -> comicTeamRepository.save(ComicTeamEntity.builder().name(name).build())))
.forEach(entity -> c.getTeams().add(entity));
}
private void updateComicLocations(ComicMetadataEntity c, Set<String> locations, MetadataReplaceMode mode) {
if (locations == null || locations.isEmpty()) {
if (mode == MetadataReplaceMode.REPLACE_ALL) {
c.getLocations().clear();
}
return;
}
if (c.getLocations() == null) {
c.setLocations(new HashSet<>());
}
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
c.getLocations().clear();
}
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getLocations().isEmpty()) {
return;
}
locations.stream()
.map(name -> comicLocationRepository.findByName(name)
.orElseGet(() -> comicLocationRepository.save(ComicLocationEntity.builder().name(name).build())))
.forEach(entity -> c.getLocations().add(entity));
}
private void updateComicCreators(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());
if (!hasNewCreators) {
if (mode == MetadataReplaceMode.REPLACE_ALL) {
c.getCreatorMappings().clear();
}
return;
}
if (mode == MetadataReplaceMode.REPLACE_ALL || mode == MetadataReplaceMode.REPLACE_WHEN_PROVIDED) {
c.getCreatorMappings().clear();
}
if (mode == MetadataReplaceMode.REPLACE_MISSING && !c.getCreatorMappings().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);
}
private void addCreatorsWithRole(ComicMetadataEntity comic, Set<String> names, ComicCreatorRole role) {
if (names == null || names.isEmpty()) {
return;
}
for (String name : names) {
ComicCreatorEntity creator = comicCreatorRepository.findByName(name)
.orElseGet(() -> comicCreatorRepository.save(ComicCreatorEntity.builder().name(name).build()));
ComicCreatorMappingEntity mapping = ComicCreatorMappingEntity.builder()
.comicMetadata(comic)
.creator(creator)
.role(role)
.build();
comic.getCreatorMappings().add(mapping);
}
}
private void updateThumbnailIfNeeded(long bookId, BookEntity bookEntity, BookMetadata m, BookMetadataEntity e, boolean set) {
if (Boolean.TRUE.equals(e.getCoverLocked())) {
return;

View File

@@ -1,13 +1,14 @@
package org.booklore.service.metadata.extractor;
import org.booklore.model.dto.BookMetadata;
import org.booklore.util.ArchiveUtils;
import com.github.junrar.Archive;
import com.github.junrar.rarfile.FileHeader;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.archivers.sevenz.SevenZArchiveEntry;
import org.apache.commons.compress.archivers.sevenz.SevenZFile;
import org.apache.commons.io.FilenameUtils;
import org.booklore.model.dto.BookMetadata;
import org.booklore.model.dto.ComicMetadata;
import org.booklore.util.ArchiveUtils;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
@@ -80,29 +81,29 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
}
// CBR path (RAR)
if (type == ArchiveUtils.ArchiveType.RAR) {
try (Archive archive = new Archive(file)) {
try {
FileHeader header = findComicInfoHeader(archive);
if (header == null) {
if (type == ArchiveUtils.ArchiveType.RAR) {
try (Archive archive = new Archive(file)) {
try {
FileHeader header = findComicInfoHeader(archive);
if (header == null) {
return BookMetadata.builder().title(baseName).build();
}
byte[] xmlBytes = readRarEntryBytes(archive, header);
if (xmlBytes == null) {
return BookMetadata.builder().title(baseName).build();
}
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
Document document = buildSecureDocument(is);
return mapDocumentToMetadata(document, baseName);
}
} catch (Exception e) {
log.warn("Failed to extract metadata from CBR", e);
return BookMetadata.builder().title(baseName).build();
}
byte[] xmlBytes = readRarEntryBytes(archive, header);
if (xmlBytes == null) {
return BookMetadata.builder().title(baseName).build();
}
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
Document document = buildSecureDocument(is);
return mapDocumentToMetadata(document, baseName);
}
} catch (Exception e) {
log.warn("Failed to extract metadata from CBR", e);
return BookMetadata.builder().title(baseName).build();
} catch (Exception ignore) {
}
} catch (Exception ignore) {
}
}
return BookMetadata.builder().title(baseName).build();
return BookMetadata.builder().title(baseName).build();
}
private ZipEntry findComicInfoEntry(ZipFile zipFile) {
@@ -205,6 +206,137 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
builder.categories(categories);
}
// Extract comic-specific metadata
ComicMetadata.ComicMetadataBuilder comicBuilder = ComicMetadata.builder();
boolean hasComicFields = false;
String issueNumber = getTextContent(document, "Number");
if (issueNumber != null && !issueNumber.isBlank()) {
comicBuilder.issueNumber(issueNumber);
hasComicFields = true;
}
String volume = getTextContent(document, "Volume");
if (volume != null && !volume.isBlank()) {
comicBuilder.volumeName(getTextContent(document, "Series"));
comicBuilder.volumeNumber(parseInteger(volume));
hasComicFields = true;
}
String storyArc = getTextContent(document, "StoryArc");
if (storyArc != null && !storyArc.isBlank()) {
comicBuilder.storyArc(storyArc);
comicBuilder.storyArcNumber(parseInteger(getTextContent(document, "StoryArcNumber")));
hasComicFields = true;
}
String alternateSeries = getTextContent(document, "AlternateSeries");
if (alternateSeries != null && !alternateSeries.isBlank()) {
comicBuilder.alternateSeries(alternateSeries);
comicBuilder.alternateIssue(getTextContent(document, "AlternateNumber"));
hasComicFields = true;
}
Set<String> pencillers = splitValues(getTextContent(document, "Penciller"));
if (!pencillers.isEmpty()) {
comicBuilder.pencillers(pencillers);
hasComicFields = true;
}
Set<String> inkers = splitValues(getTextContent(document, "Inker"));
if (!inkers.isEmpty()) {
comicBuilder.inkers(inkers);
hasComicFields = true;
}
Set<String> colorists = splitValues(getTextContent(document, "Colorist"));
if (!colorists.isEmpty()) {
comicBuilder.colorists(colorists);
hasComicFields = true;
}
Set<String> letterers = splitValues(getTextContent(document, "Letterer"));
if (!letterers.isEmpty()) {
comicBuilder.letterers(letterers);
hasComicFields = true;
}
Set<String> coverArtists = splitValues(getTextContent(document, "CoverArtist"));
if (!coverArtists.isEmpty()) {
comicBuilder.coverArtists(coverArtists);
hasComicFields = true;
}
Set<String> editors = splitValues(getTextContent(document, "Editor"));
if (!editors.isEmpty()) {
comicBuilder.editors(editors);
hasComicFields = true;
}
String imprint = getTextContent(document, "Imprint");
if (imprint != null && !imprint.isBlank()) {
comicBuilder.imprint(imprint);
hasComicFields = true;
}
String format = getTextContent(document, "Format");
if (format != null && !format.isBlank()) {
comicBuilder.format(format);
hasComicFields = true;
}
String blackAndWhite = getTextContent(document, "BlackAndWhite");
if ("yes".equalsIgnoreCase(blackAndWhite) || "true".equalsIgnoreCase(blackAndWhite)) {
comicBuilder.blackAndWhite(Boolean.TRUE);
hasComicFields = true;
}
String manga = getTextContent(document, "Manga");
if (manga != null && !manga.isBlank()) {
boolean isManga = "yes".equalsIgnoreCase(manga) || "true".equalsIgnoreCase(manga) || "yesandrighttoleft".equalsIgnoreCase(manga);
comicBuilder.manga(isManga);
if ("yesandrighttoleft".equalsIgnoreCase(manga)) {
comicBuilder.readingDirection("rtl");
} else {
comicBuilder.readingDirection("ltr");
}
hasComicFields = true;
}
Set<String> characters = splitValues(getTextContent(document, "Characters"));
if (!characters.isEmpty()) {
comicBuilder.characters(characters);
hasComicFields = true;
}
Set<String> teams = splitValues(getTextContent(document, "Teams"));
if (!teams.isEmpty()) {
comicBuilder.teams(teams);
hasComicFields = true;
}
Set<String> locations = splitValues(getTextContent(document, "Locations"));
if (!locations.isEmpty()) {
comicBuilder.locations(locations);
hasComicFields = true;
}
String web = getTextContent(document, "Web");
if (web != null && !web.isBlank()) {
comicBuilder.webLink(web);
hasComicFields = true;
}
String notes = getTextContent(document, "Notes");
if (notes != null && !notes.isBlank()) {
comicBuilder.notes(notes);
hasComicFields = true;
}
if (hasComicFields) {
builder.comicMetadata(comicBuilder.build());
}
return builder.build();
}
@@ -437,62 +569,62 @@ public class CbxMetadataExtractor implements FileMetadataExtractor {
}
// CBR path
if (type == ArchiveUtils.ArchiveType.RAR) {
try (Archive archive = new Archive(file)) {
try {
if (type == ArchiveUtils.ArchiveType.RAR) {
try (Archive archive = new Archive(file)) {
try {
// Try via ComicInfo.xml first
FileHeader comicInfo = findComicInfoHeader(archive);
if (comicInfo != null) {
byte[] xmlBytes = readRarEntryBytes(archive, comicInfo);
if (xmlBytes != null) {
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
Document document = buildSecureDocument(is);
String imageName = findFrontCoverImageName(document);
if (imageName != null) {
FileHeader byName = findRarHeaderByName(archive, imageName);
if (byName != null) {
byte[] bytes = readRarEntryBytes(archive, byName);
if (canDecode(bytes)) return bytes;
}
try {
int index = Integer.parseInt(imageName);
FileHeader byIndex = findRarImageHeaderByIndex(archive, index);
if (byIndex != null) {
byte[] bytes = readRarEntryBytes(archive, byIndex);
// Try via ComicInfo.xml first
FileHeader comicInfo = findComicInfoHeader(archive);
if (comicInfo != null) {
byte[] xmlBytes = readRarEntryBytes(archive, comicInfo);
if (xmlBytes != null) {
try (InputStream is = new ByteArrayInputStream(xmlBytes)) {
Document document = buildSecureDocument(is);
String imageName = findFrontCoverImageName(document);
if (imageName != null) {
FileHeader byName = findRarHeaderByName(archive, imageName);
if (byName != null) {
byte[] bytes = readRarEntryBytes(archive, byName);
if (canDecode(bytes)) return bytes;
}
if (index > 0) {
FileHeader offByOne = findRarImageHeaderByIndex(archive, index - 1);
if (offByOne != null) {
byte[] bytes = readRarEntryBytes(archive, offByOne);
try {
int index = Integer.parseInt(imageName);
FileHeader byIndex = findRarImageHeaderByIndex(archive, index);
if (byIndex != null) {
byte[] bytes = readRarEntryBytes(archive, byIndex);
if (canDecode(bytes)) return bytes;
}
if (index > 0) {
FileHeader offByOne = findRarImageHeaderByIndex(archive, index - 1);
if (offByOne != null) {
byte[] bytes = readRarEntryBytes(archive, offByOne);
if (canDecode(bytes)) return bytes;
}
}
} catch (NumberFormatException ignore) {
// ignore and continue fallback
}
} catch (NumberFormatException ignore) {
// ignore and continue fallback
}
}
}
}
}
// Fallback: iterate images alphabetically until a decodable one is found
FileHeader firstImage = findFirstAlphabeticalImageHeader(archive);
if (firstImage != null) {
List<FileHeader> images = listRarImageHeaders(archive);
for (FileHeader fh : images) {
byte[] bytes = readRarEntryBytes(archive, fh);
if (canDecode(bytes)) return bytes;
// Fallback: iterate images alphabetically until a decodable one is found
FileHeader firstImage = findFirstAlphabeticalImageHeader(archive);
if (firstImage != null) {
List<FileHeader> images = listRarImageHeaders(archive);
for (FileHeader fh : images) {
byte[] bytes = readRarEntryBytes(archive, fh);
if (canDecode(bytes)) return bytes;
}
}
} catch (Exception e) {
log.warn("Failed to extract cover image from CBR", e);
return generatePlaceholderCover(250, 350);
}
} catch (Exception e) {
log.warn("Failed to extract cover image from CBR", e);
return generatePlaceholderCover(250, 350);
} catch (Exception ignore) {
}
} catch (Exception ignore) {
}
}
return generatePlaceholderCover(250, 350);
}

View File

@@ -3,6 +3,7 @@ package org.booklore.util;
import lombok.experimental.UtilityClass;
import org.booklore.model.MetadataClearFlags;
import org.booklore.model.dto.BookMetadata;
import org.booklore.model.dto.ComicMetadata;
import org.booklore.model.entity.*;
import java.util.*;
@@ -245,6 +246,9 @@ public class MetadataChangeDetector {
return true;
}
}
if (hasComicMetadataChanges(newMeta, existingMeta)) {
return true;
}
return differsLock(newMeta.getCoverLocked(), existingMeta.getCoverLocked()) || differsLock(newMeta.getAudiobookCoverLocked(), existingMeta.getAudiobookCoverLocked());
}
@@ -259,6 +263,9 @@ public class MetadataChangeDetector {
return true;
}
}
if (hasComicMetadataChanges(newMeta, existingMeta)) {
return true;
}
return false;
}
@@ -361,4 +368,90 @@ public class MetadataChangeDetector {
})
.collect(Collectors.toSet());
}
private static boolean hasComicMetadataChanges(BookMetadata newMeta, BookMetadataEntity existingMeta) {
ComicMetadata comicDto = newMeta.getComicMetadata();
ComicMetadataEntity comicEntity = existingMeta.getComicMetadata();
// No comic metadata in DTO, no changes
if (comicDto == null) {
return false;
}
// Comic metadata in DTO but not in entity - this is a change
if (comicEntity == null) {
return true;
}
// 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()))
|| !stringSetsEqual(comicDto.getCharacters(), extractCharacterNames(comicEntity.getCharacters()))
|| !stringSetsEqual(comicDto.getTeams(), extractTeamNames(comicEntity.getTeams()))
|| !stringSetsEqual(comicDto.getLocations(), extractLocationNames(comicEntity.getLocations()))
|| hasCreatorChanges(comicDto, comicEntity);
}
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;
if (set1.isEmpty() && set2.isEmpty()) return true;
return set1.equals(set2);
}
private static Set<String> extractCharacterNames(Set<ComicCharacterEntity> entities) {
if (entities == null) return Collections.emptySet();
return entities.stream().map(ComicCharacterEntity::getName).collect(Collectors.toSet());
}
private static Set<String> extractTeamNames(Set<ComicTeamEntity> entities) {
if (entities == null) return Collections.emptySet();
return entities.stream().map(ComicTeamEntity::getName).collect(Collectors.toSet());
}
private static Set<String> extractLocationNames(Set<ComicLocationEntity> entities) {
if (entities == null) return Collections.emptySet();
return entities.stream().map(ComicLocationEntity::getName).collect(Collectors.toSet());
}
private static boolean hasCreatorChanges(ComicMetadata dto, ComicMetadataEntity entity) {
// For creators, we do a simplified comparison based on whether there are any creators in DTO
boolean dtoHasCreators = (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());
boolean entityHasCreators = entity.getCreatorMappings() != null && !entity.getCreatorMappings().isEmpty();
// If both have no creators, no change
if (!dtoHasCreators && !entityHasCreators) return false;
// If one has creators and other doesn't, there's a change
if (dtoHasCreators != entityHasCreators) return true;
// Both have creators - compare counts as a basic check
int dtoCount = countNonNull(dto.getPencillers()) + countNonNull(dto.getInkers())
+ countNonNull(dto.getColorists()) + countNonNull(dto.getLetterers())
+ countNonNull(dto.getCoverArtists()) + countNonNull(dto.getEditors());
return dtoCount != entity.getCreatorMappings().size();
}
private static int countNonNull(Set<String> set) {
return set == null ? 0 : set.size();
}
}

View File

@@ -0,0 +1,101 @@
-- Create comic_metadata table for storing comic-specific metadata
CREATE TABLE IF NOT EXISTS comic_metadata
(
book_id BIGINT PRIMARY KEY,
issue_number VARCHAR(50),
volume_name VARCHAR(255),
volume_number INTEGER,
story_arc VARCHAR(255),
story_arc_number INTEGER,
alternate_series VARCHAR(255),
alternate_issue VARCHAR(50),
imprint VARCHAR(255),
format VARCHAR(50),
black_and_white BOOLEAN DEFAULT FALSE,
manga BOOLEAN DEFAULT FALSE,
reading_direction VARCHAR(10) DEFAULT 'ltr',
web_link VARCHAR(1000),
notes TEXT,
issue_number_locked BOOLEAN DEFAULT FALSE,
volume_name_locked BOOLEAN DEFAULT FALSE,
volume_number_locked BOOLEAN DEFAULT FALSE,
story_arc_locked BOOLEAN DEFAULT FALSE,
creators_locked BOOLEAN DEFAULT FALSE,
characters_locked BOOLEAN DEFAULT FALSE,
teams_locked BOOLEAN DEFAULT FALSE,
locations_locked BOOLEAN DEFAULT FALSE,
CONSTRAINT fk_comic_metadata_book FOREIGN KEY (book_id) REFERENCES book_metadata (book_id) ON DELETE CASCADE
);
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS idx_comic_metadata_story_arc ON comic_metadata (story_arc);
CREATE INDEX IF NOT EXISTS idx_comic_metadata_volume_name ON comic_metadata (volume_name);
-- Create comic_character table
CREATE TABLE IF NOT EXISTS comic_character
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
);
-- Create comic_team table
CREATE TABLE IF NOT EXISTS comic_team
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
);
-- Create comic_location table
CREATE TABLE IF NOT EXISTS comic_location
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
);
-- Create comic_creator table
CREATE TABLE IF NOT EXISTS comic_creator
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
);
-- Create mapping tables
CREATE TABLE IF NOT EXISTS comic_metadata_character_mapping
(
book_id BIGINT NOT NULL,
character_id BIGINT NOT NULL,
PRIMARY KEY (book_id, character_id),
CONSTRAINT fk_comic_char_mapping_book FOREIGN KEY (book_id) REFERENCES comic_metadata (book_id) ON DELETE CASCADE,
CONSTRAINT fk_comic_char_mapping_char FOREIGN KEY (character_id) REFERENCES comic_character (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS comic_metadata_team_mapping
(
book_id BIGINT NOT NULL,
team_id BIGINT NOT NULL,
PRIMARY KEY (book_id, team_id),
CONSTRAINT fk_comic_team_mapping_book FOREIGN KEY (book_id) REFERENCES comic_metadata (book_id) ON DELETE CASCADE,
CONSTRAINT fk_comic_team_mapping_team FOREIGN KEY (team_id) REFERENCES comic_team (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS comic_metadata_location_mapping
(
book_id BIGINT NOT NULL,
location_id BIGINT NOT NULL,
PRIMARY KEY (book_id, location_id),
CONSTRAINT fk_comic_loc_mapping_book FOREIGN KEY (book_id) REFERENCES comic_metadata (book_id) ON DELETE CASCADE,
CONSTRAINT fk_comic_loc_mapping_loc FOREIGN KEY (location_id) REFERENCES comic_location (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS comic_metadata_creator_mapping
(
id BIGINT AUTO_INCREMENT PRIMARY KEY,
book_id BIGINT NOT NULL,
creator_id BIGINT NOT NULL,
role VARCHAR(20) NOT NULL,
CONSTRAINT fk_comic_creator_mapping_book FOREIGN KEY (book_id) REFERENCES comic_metadata (book_id) ON DELETE CASCADE,
CONSTRAINT fk_comic_creator_mapping_creator FOREIGN KEY (creator_id) REFERENCES comic_creator (id) ON DELETE CASCADE
);
CREATE INDEX idx_comic_creator_mapping_role ON comic_metadata_creator_mapping (role);
CREATE INDEX idx_comic_creator_mapping_book ON comic_metadata_creator_mapping (book_id);