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

View File

@@ -110,6 +110,40 @@ export interface AudiobookMetadata {
abridgedLocked?: boolean;
}
export interface ComicMetadata {
issueNumber?: string;
volumeName?: string;
volumeNumber?: number;
storyArc?: string;
storyArcNumber?: number;
alternateSeries?: string;
alternateIssue?: string;
pencillers?: string[];
inkers?: string[];
colorists?: string[];
letterers?: string[];
coverArtists?: string[];
editors?: string[];
imprint?: string;
format?: string;
blackAndWhite?: boolean;
manga?: boolean;
readingDirection?: string;
characters?: string[];
teams?: string[];
locations?: string[];
webLink?: string;
notes?: string;
issueNumberLocked?: boolean;
volumeNameLocked?: boolean;
volumeNumberLocked?: boolean;
storyArcLocked?: boolean;
creatorsLocked?: boolean;
charactersLocked?: boolean;
teamsLocked?: boolean;
locationsLocked?: boolean;
}
export interface BookMetadata {
bookId: number;
title?: string;
@@ -150,6 +184,7 @@ export interface BookMetadata {
narratorLocked?: boolean;
abridgedLocked?: boolean;
audiobookMetadata?: AudiobookMetadata;
comicMetadata?: ComicMetadata;
coverUpdatedOn?: string;
audiobookCoverUpdatedOn?: string;
authors?: string[];

View File

@@ -363,14 +363,15 @@
<span class="metadata-key">Formats:</span>
<span class="metadata-value-group format-tags">
@if (getDisplayFormat(book?.primaryFile); as displayFormat) {
<span (click)="goToFileType(book?.primaryFile?.filePath)" style="cursor: pointer;" [pTooltip]="'Primary: ' + (book?.primaryFile?.fileName ?? '')" tooltipPosition="top">
<span class="format-tag-wrapper primary-format" (click)="goToFileType(book?.primaryFile?.filePath)" [pTooltip]="book?.primaryFile?.fileName ?? ''" tooltipPosition="top">
<app-tag size="3xs" variant="pill" [customBgColor]="getFileTypeBgColor(displayFormat)" customTextColor="#fff">
{{ displayFormat }}
</app-tag>
<span class="format-badge">PRIMARY</span>
</span>
}
@for (formatExt of getUniqueAlternativeFormats(book); track formatExt) {
<span (click)="goToFileType('.' + formatExt)" style="cursor: pointer;" [pTooltip]="formatExt" tooltipPosition="top">
<span class="format-tag-wrapper" (click)="goToFileType('.' + formatExt)" [pTooltip]="formatExt" tooltipPosition="top">
<app-tag size="3xs" variant="pill" [customBgColor]="getFileTypeBgColor(formatExt)" customTextColor="#fff">
{{ formatExt }}
</app-tag>
@@ -572,100 +573,405 @@
</div>
@if (userService.userState$ | async; as userState) {
<div class="action-buttons">
@if (navigationState$ | async) {
<div class="navigation-buttons-desktop">
<p-button
icon="pi pi-chevron-left"
[disabled]="!canNavigatePrevious()"
(onClick)="navigatePrevious()"
rounded
outlined
severity="info"
pTooltip="Go to previous book"
tooltipPosition="bottom">
</p-button>
<span class="navigation-position">
{{ getNavigationPosition() }}
</span>
<p-button
icon="pi pi-chevron-right"
iconPos="right"
[disabled]="!canNavigateNext()"
(onClick)="navigateNext()"
severity="info"
rounded
outlined
pTooltip="Go to next book"
tooltipPosition="bottom">
</p-button>
<div class="action-buttons-container">
<div class="action-buttons">
@if (navigationState$ | async) {
<div class="action-group navigation-group">
<p-button
icon="pi pi-chevron-left"
[disabled]="!canNavigatePrevious()"
(onClick)="navigatePrevious()"
rounded
text
severity="secondary"
pTooltip="Go to previous book"
tooltipPosition="bottom">
</p-button>
<span class="navigation-position">
{{ getNavigationPosition() }}
</span>
<p-button
icon="pi pi-chevron-right"
iconPos="right"
[disabled]="!canNavigateNext()"
(onClick)="navigateNext()"
severity="secondary"
rounded
text
pTooltip="Go to next book"
tooltipPosition="bottom">
</p-button>
</div>
}
@if (hasDigitalFile(book)) {
<div class="action-group primary-actions">
@if (readMenuItems$ | async; as readItems) {
@if (readItems.length > 0) {
<p-splitbutton [label]="getReadButtonLabel(book)" [icon]="getReadButtonIcon(book)" [model]="readItems" (onClick)="read(book.id)" severity="primary" class="read-button-responsive"/>
} @else {
<p-button [label]="getReadButtonLabel(book)" [icon]="getReadButtonIcon(book)" (onClick)="read(book.id)" severity="primary" class="read-button-responsive"/>
}
} @else {
<p-button [label]="getReadButtonLabel(book)" [icon]="getReadButtonIcon(book)" (onClick)="read(book.id)" severity="primary" class="read-button-responsive"/>
}
</div>
}
<div class="action-group secondary-actions">
@if ((userState.user!.permissions.canDownload || userState.user!.permissions.admin) && hasAnyFiles(book)) {
@if ((book!.alternativeFormats && book!.alternativeFormats.length > 0) || (book!.supplementaryFiles && book!.supplementaryFiles.length > 0)) {
@if (downloadMenuItems$ | async; as downloadItems) {
<p-splitbutton [label]="(getDisplayFormat(book.primaryFile) ?? 'File') + ' · ' + formatFileSize(book.primaryFile)" icon="pi pi-download" [model]="downloadItems" (onClick)="download(book)" severity="success" outlined class="mobile-icon-only"/>
}
} @else {
<p-button [label]="(getDisplayFormat(book.primaryFile) ?? 'File') + ' · ' + formatFileSize(book.primaryFile)" icon="pi pi-download" severity="success" outlined (onClick)="download(book)" class="mobile-icon-only"></p-button>
}
}
@if (userState.user!.permissions.canEditMetadata || userState.user!.permissions.admin) {
<p-button
[label]="isAutoFetching ? 'Fetching...' : 'Fetch Metadata'"
[icon]="isAutoFetching ? 'pi pi-spin pi-spinner' : 'pi pi-bolt'"
[outlined]="true"
severity="warn"
(onClick)="quickRefresh(book!.id)"
[disabled]="isAutoFetching"
pTooltip="Automatically fetch metadata using default sources"
tooltipPosition="top"
class="mobile-icon-only">
</p-button>
}
@if (otherItems$ | async; as otherItems) {
@if (otherItems.length > 0) {
<p-button icon="pi pi-ellipsis-v" severity="info" outlined (click)="entitymenu.toggle($event)"></p-button>
<p-tieredMenu #entitymenu [model]="otherItems" [popup]="true" appendTo="body"></p-tieredMenu>
}
}
</div>
<p-divider layout="vertical" class="action-divider"></p-divider>
}
@if (hasDigitalFile(book)) {
@if (readMenuItems$ | async; as readItems) {
@if (readItems.length > 0) {
<p-splitbutton [label]="getReadButtonLabel(book)" [icon]="getReadButtonIcon(book)" [model]="readItems" (onClick)="read(book.id)" outlined severity="primary" class="read-button-responsive"/>
} @else {
<p-button [label]="getReadButtonLabel(book)" [icon]="getReadButtonIcon(book)" (onClick)="read(book.id)" outlined severity="primary" class="read-button-responsive"/>
}
} @else {
<p-button [label]="getReadButtonLabel(book)" [icon]="getReadButtonIcon(book)" (onClick)="read(book.id)" outlined severity="primary" class="read-button-responsive"/>
}
<p-divider layout="vertical"></p-divider>
}
@if ((userState.user!.permissions.canDownload || userState.user!.permissions.admin) && hasAnyFiles(book)) {
@if ((book!.alternativeFormats && book!.alternativeFormats.length > 0) || (book!.supplementaryFiles && book!.supplementaryFiles.length > 0)) {
@if (downloadMenuItems$ | async; as downloadItems) {
<p-splitbutton [label]="(getDisplayFormat(book.primaryFile) ?? 'File') + ' · ' + formatFileSize(book.primaryFile)" icon="pi pi-download" [model]="downloadItems" (onClick)="download(book)" severity="success" outlined class="mobile-icon-only"/>
}
} @else {
<p-button [label]="(getDisplayFormat(book.primaryFile) ?? 'File') + ' · ' + formatFileSize(book.primaryFile)" icon="pi pi-download" severity="success" outlined (onClick)="download(book)" class="mobile-icon-only"></p-button>
}
}
@if (userState.user!.permissions.canEditMetadata || userState.user!.permissions.admin) {
<p-button
[label]="isAutoFetching ? 'Fetching...' : 'Fetch Metadata'"
[icon]="isAutoFetching ? 'pi pi-spin pi-spinner' : 'pi pi-bolt'"
[outlined]="true"
severity="warn"
(onClick)="quickRefresh(book!.id)"
[disabled]="isAutoFetching"
pTooltip="Automatically fetch metadata using default sources"
tooltipPosition="top"
class="mobile-icon-only">
</p-button>
}
@if (otherItems$ | async; as otherItems) {
@if (otherItems.length > 0) {
<p-button icon="pi pi-ellipsis-v" outlined severity="info" (click)="entitymenu.toggle($event)"></p-button>
<p-tieredMenu #entitymenu [model]="otherItems" [popup]="true" appendTo="body"></p-tieredMenu>
}
}
</div>
</div>
}
</div>
</div>
<div class="description-section">
<div [ngClass]="{ 'line-clamp-7': !isExpanded }" class="description-content">
<div class="readonly-editor">
<p-editor #quillEditor [readonly]="true" [(ngModel)]="book.metadata!.description">
<ng-template #header></ng-template>
</p-editor>
<div class="section-header">
<i class="pi pi-align-left"></i>
<span>Synopsis</span>
</div>
<div class="description-body">
<div [ngClass]="{ 'line-clamp-7': !isExpanded }" class="description-content">
<div class="readonly-editor">
<p-editor #quillEditor [readonly]="true" [(ngModel)]="book.metadata!.description">
<ng-template #header></ng-template>
</p-editor>
</div>
</div>
@if (book.metadata!.description && book.metadata!.description.length > 500) {
<button class="expand-toggle" (click)="toggleExpand()">
{{ isExpanded ? 'Show less' : 'Show more' }}
<i class="pi" [ngClass]="isExpanded ? 'pi-chevron-up' : 'pi-chevron-down'"></i>
</button>
}
</div>
</div>
@if (isComicBook(book) && hasComicMetadata(book)) {
<div class="comic-metadata-section">
<div class="section-header clickable" (click)="isComicSectionExpanded = !isComicSectionExpanded">
<div class="section-header-left">
<i class="pi pi-images"></i>
<span>Comic Details</span>
</div>
<i class="pi section-toggle-icon" [ngClass]="isComicSectionExpanded ? 'pi-chevron-up' : 'pi-chevron-down'"></i>
</div>
<div class="comic-metadata-content" [ngClass]="{'collapsed': !isComicSectionExpanded}">
@let comic = book.metadata!.comicMetadata!;
<div class="comic-info-grid">
@if (comic.issueNumber) {
<div class="comic-info-item">
<span class="comic-info-label">Issue</span>
<span class="comic-info-value">#{{ comic.issueNumber }}</span>
</div>
}
@if (comic.volumeName) {
<div class="comic-info-item">
<span class="comic-info-label">Volume</span>
<span class="comic-info-value">
{{ comic.volumeName }}
@if (comic.volumeNumber) {
<span class="comic-volume-number">Vol. {{ comic.volumeNumber }}</span>
}
</span>
</div>
}
@if (comic.storyArc) {
<div class="comic-info-item">
<span class="comic-info-label">Story Arc</span>
<span class="comic-info-value">
{{ comic.storyArc }}
@if (comic.storyArcNumber) {
<span class="comic-arc-number">(#{{ comic.storyArcNumber }})</span>
}
</span>
</div>
}
@if (comic.alternateSeries) {
<div class="comic-info-item">
<span class="comic-info-label">Alt. Series</span>
<span class="comic-info-value">
{{ comic.alternateSeries }}
@if (comic.alternateIssue) {
#{{ comic.alternateIssue }}
}
</span>
</div>
}
@if (comic.format) {
<div class="comic-info-item">
<span class="comic-info-label">Format</span>
<span class="comic-info-value">{{ comic.format }}</span>
</div>
}
@if (comic.imprint) {
<div class="comic-info-item">
<span class="comic-info-label">Imprint</span>
<span class="comic-info-value">{{ comic.imprint }}</span>
</div>
}
</div>
@if (hasAnyCreators(comic)) {
<div class="comic-subsection">
<div class="comic-subsection-title">
<i class="pi pi-users"></i>
<span>Creators</span>
</div>
<div class="comic-creators-grid">
@if (comic.pencillers?.length) {
<div class="comic-creator-row">
<span class="comic-creator-role">Pencillers</span>
<div class="comic-creator-names">
@for (name of comic.pencillers; track name) {
<a class="comic-creator-link" (click)="goToCreator(name, 'penciller')">{{ name }}</a>
}
</div>
</div>
}
@if (comic.inkers?.length) {
<div class="comic-creator-row">
<span class="comic-creator-role">Inkers</span>
<div class="comic-creator-names">
@for (name of comic.inkers; track name) {
<a class="comic-creator-link" (click)="goToCreator(name, 'inker')">{{ name }}</a>
}
</div>
</div>
}
@if (comic.colorists?.length) {
<div class="comic-creator-row">
<span class="comic-creator-role">Colorists</span>
<div class="comic-creator-names">
@for (name of comic.colorists; track name) {
<a class="comic-creator-link" (click)="goToCreator(name, 'colorist')">{{ name }}</a>
}
</div>
</div>
}
@if (comic.letterers?.length) {
<div class="comic-creator-row">
<span class="comic-creator-role">Letterers</span>
<div class="comic-creator-names">
@for (name of comic.letterers; track name) {
<a class="comic-creator-link" (click)="goToCreator(name, 'letterer')">{{ name }}</a>
}
</div>
</div>
}
@if (comic.coverArtists?.length) {
<div class="comic-creator-row">
<span class="comic-creator-role">Cover Artists</span>
<div class="comic-creator-names">
@for (name of comic.coverArtists; track name) {
<a class="comic-creator-link" (click)="goToCreator(name, 'coverArtist')">{{ name }}</a>
}
</div>
</div>
}
@if (comic.editors?.length) {
<div class="comic-creator-row">
<span class="comic-creator-role">Editors</span>
<div class="comic-creator-names">
@for (name of comic.editors; track name) {
<a class="comic-creator-link" (click)="goToCreator(name, 'editor')">{{ name }}</a>
}
</div>
</div>
}
</div>
</div>
}
@if (comic.characters?.length || comic.teams?.length || comic.locations?.length) {
<div class="comic-subsection">
<div class="comic-subsection-title">
<i class="pi pi-globe"></i>
<span>Universe</span>
</div>
<div class="comic-tags-container">
@if (comic.characters?.length) {
<div class="comic-tag-row">
<span class="comic-tag-label">Characters</span>
<div class="comic-tag-list">
@for (character of comic.characters; track character) {
<a class="comic-tag-clickable" (click)="goToCharacter(character)">
<app-tag color="cyan" size="xs">{{ character }}</app-tag>
</a>
}
</div>
</div>
}
@if (comic.teams?.length) {
<div class="comic-tag-row">
<span class="comic-tag-label">Teams</span>
<div class="comic-tag-list">
@for (team of comic.teams; track team) {
<a class="comic-tag-clickable" (click)="goToTeam(team)">
<app-tag color="violet" size="xs">{{ team }}</app-tag>
</a>
}
</div>
</div>
}
@if (comic.locations?.length) {
<div class="comic-tag-row">
<span class="comic-tag-label">Locations</span>
<div class="comic-tag-list">
@for (location of comic.locations; track location) {
<a class="comic-tag-clickable" (click)="goToLocation(location)">
<app-tag color="amber" size="xs">{{ location }}</app-tag>
</a>
}
</div>
</div>
}
</div>
</div>
}
@if (comic.manga || comic.blackAndWhite || comic.readingDirection === 'rtl') {
<div class="comic-properties">
@if (comic.manga) {
<span class="comic-property-badge manga">
<i class="pi pi-book"></i> Manga
</span>
}
@if (comic.blackAndWhite) {
<span class="comic-property-badge bw">
<i class="pi pi-palette"></i> B&W
</span>
}
@if (comic.readingDirection === 'rtl') {
<span class="comic-property-badge rtl">
<i class="pi pi-arrow-left"></i> RTL
</span>
}
</div>
}
@if (comic.webLink) {
<div class="comic-web-link">
<a [href]="comic.webLink" target="_blank" rel="noopener noreferrer" class="comic-external-link">
<i class="pi pi-external-link"></i>
<span>{{ formatWebLink(comic.webLink) }}</span>
</a>
</div>
}
@if (comic.notes) {
<div class="comic-notes">
<div class="comic-notes-label">
<i class="pi pi-file-edit"></i>
<span>Notes</span>
</div>
<p class="comic-notes-content">{{ comic.notes }}</p>
</div>
}
</div>
</div>
@if (book.metadata!.description && book.metadata!.description.length > 500) {
<p-button
[label]="isExpanded ? 'Show less' : 'Show more'"
[icon]="isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
iconPos="right"
size="small"
text
(click)="toggleExpand()">
</p-button>
}
</div>
}
@if (isAudiobook(book) && hasAudiobookMetadata(book)) {
<div class="audiobook-metadata-section">
<div class="section-header clickable" (click)="isAudiobookSectionExpanded = !isAudiobookSectionExpanded">
<div class="section-header-left">
<i class="pi pi-headphones"></i>
<span>Audiobook Details</span>
</div>
<i class="pi section-toggle-icon" [ngClass]="isAudiobookSectionExpanded ? 'pi-chevron-up' : 'pi-chevron-down'"></i>
</div>
<div class="audiobook-metadata-content" [ngClass]="{'collapsed': !isAudiobookSectionExpanded}">
@let audio = book.metadata!.audiobookMetadata!;
<div class="audiobook-info-grid">
@if (audio.durationSeconds) {
<div class="audiobook-info-item">
<span class="audiobook-info-label">Duration</span>
<span class="audiobook-info-value">{{ formatDuration(audio.durationSeconds) }}</span>
</div>
}
@if (book.metadata?.narrator) {
<div class="audiobook-info-item">
<span class="audiobook-info-label">Narrator</span>
<a class="audiobook-info-value audiobook-link" (click)="goToNarrator(book.metadata!.narrator!)">
{{ book.metadata?.narrator }}
</a>
</div>
}
@if (audio.chapterCount) {
<div class="audiobook-info-item">
<span class="audiobook-info-label">Chapters</span>
<span class="audiobook-info-value">{{ audio.chapterCount }}</span>
</div>
}
@if (audio.bitrate) {
<div class="audiobook-info-item">
<span class="audiobook-info-label">Bitrate</span>
<span class="audiobook-info-value">{{ audio.bitrate }} kbps</span>
</div>
}
@if (audio.sampleRate) {
<div class="audiobook-info-item">
<span class="audiobook-info-label">Sample Rate</span>
<span class="audiobook-info-value">{{ formatSampleRate(audio.sampleRate) }}</span>
</div>
}
@if (audio.channels) {
<div class="audiobook-info-item">
<span class="audiobook-info-label">Channels</span>
<span class="audiobook-info-value">{{ getChannelLabel(audio.channels) }}</span>
</div>
}
@if (audio.codec) {
<div class="audiobook-info-item">
<span class="audiobook-info-label">Codec</span>
<span class="audiobook-info-value">{{ audio.codec.toUpperCase() }}</span>
</div>
}
</div>
@if (book.metadata?.abridged != null) {
<div class="audiobook-properties">
<span class="audiobook-property-badge" [ngClass]="book.metadata?.abridged ? 'abridged' : 'unabridged'">
<i class="pi" [ngClass]="book.metadata?.abridged ? 'pi-minus-circle' : 'pi-check-circle'"></i>
{{ book.metadata?.abridged ? 'Abridged' : 'Unabridged' }}
</span>
</div>
}
</div>
</div>
}
<app-metadata-tabs
[book]="book"

View File

@@ -417,6 +417,38 @@
flex-wrap: wrap;
}
.format-tag-wrapper {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
transition: transform 0.15s ease;
&:hover {
transform: translateY(-1px);
}
&.primary-format {
.format-badge {
display: inline-flex;
}
}
}
.format-badge {
display: none;
font-size: 0.55rem;
font-weight: 700;
color: var(--primary-color);
text-transform: uppercase;
letter-spacing: 0.3px;
margin-left: 0.25rem;
padding: 0.1rem 0.3rem;
border: 1px solid var(--primary-color);
border-radius: 3px;
line-height: 1;
}
.metadata-link {
color: var(--primary-text-color);
cursor: pointer;
@@ -474,17 +506,45 @@
flex: 1;
}
.action-buttons-container {
margin-top: auto;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
::ng-deep .action-divider {
display: none;
.action-group {
display: flex;
align-items: center;
gap: 0.375rem;
@media (min-width: 768px) {
display: block;
&.navigation-group {
padding-right: 0.5rem;
border-right: 1px solid var(--border-color);
margin-right: 0.25rem;
@media (max-width: 767px) {
display: none;
}
}
&.primary-actions {
@media (min-width: 768px) {
padding-right: 0.5rem;
border-right: 1px solid var(--border-color);
margin-right: 0.25rem;
}
}
&.secondary-actions {
display: flex;
gap: 0.375rem;
}
}
@@ -521,39 +581,76 @@
}
}
.navigation-buttons-desktop {
display: none;
align-items: center;
gap: 0.5rem;
}
.navigation-buttons-mobile {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
@media (min-width: 768px) {
.navigation-buttons-desktop {
display: flex;
}
.navigation-buttons-mobile {
@media (min-width: 768px) {
display: none;
}
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
background: var(--surface-ground);
border-bottom: 1px solid var(--border-color);
border-radius: 0.75rem 0.75rem 0 0;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-color);
&.clickable {
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
&:hover {
background: var(--surface-hover);
}
}
i:first-child {
color: var(--primary-color);
margin-right: 0.5rem;
}
}
.section-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
i {
font-size: 0.95rem;
color: var(--primary-color);
}
}
.section-toggle-icon {
font-size: 0.8rem;
color: var(--text-color-secondary);
transition: transform 0.3s ease;
}
.description-section {
padding: 1rem;
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
overflow: hidden;
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.description-section:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
.description-body {
padding: 0.875rem;
}
.description-content {
@@ -569,6 +666,31 @@
max-height: none;
}
.expand-toggle {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
border-radius: 0.25rem;
color: var(--primary-color);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: var(--surface-hover);
color: var(--primary-hover-color);
}
i {
font-size: 0.7rem;
}
}
::ng-deep .readonly-editor .p-editor .p-editor-toolbar.ql-snow {
display: none !important;
padding: 0 !important;
@@ -598,7 +720,7 @@
::ng-deep .readonly-editor .p-editor .ql-container {
border: none !important;
height: auto !important;
font-size: 0.875rem !important;
font-size: 0.9375rem !important;
}
.loading-state {
@@ -630,3 +752,352 @@
font-size: 0.875rem;
color: var(--text-secondary-color);
}
// Comic Metadata Section Styles
.comic-metadata-section {
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
overflow: hidden;
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.comic-metadata-content {
padding: 0.875rem;
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 1000px;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
opacity: 1;
&.collapsed {
max-height: 0;
padding: 0 0.875rem;
opacity: 0;
}
}
.comic-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
}
.comic-info-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.comic-info-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.comic-info-value {
font-size: 0.95rem;
color: var(--text-color);
font-weight: 500;
}
.comic-volume-number,
.comic-arc-number {
color: var(--text-color-secondary);
font-weight: 400;
margin-left: 0.25rem;
}
.comic-subsection {
border-top: 1px solid var(--border-color);
padding-top: 0.75rem;
}
.comic-subsection-title {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.5rem;
i {
font-size: 0.9rem;
color: var(--primary-color);
}
}
.comic-creators-grid {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.comic-creator-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.9rem;
}
.comic-creator-role {
flex: 0 0 95px;
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.85rem;
}
.comic-creator-names {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
}
.comic-creator-link {
color: cornflowerblue;
cursor: pointer;
transition: color 0.2s ease;
font-size: 0.9rem;
&:hover {
color: dodgerblue;
text-decoration: underline;
}
&:not(:last-child)::after {
content: ',';
color: var(--text-color-secondary);
}
}
.comic-tags-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.comic-tag-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.comic-tag-label {
flex: 0 0 80px;
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.85rem;
padding-top: 0.125rem;
}
.comic-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.comic-tag-clickable {
cursor: pointer;
transition: transform 0.15s ease;
&:hover {
transform: translateY(-2px);
}
}
.comic-properties {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.comic-property-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
i {
font-size: 0.7rem;
}
&.manga {
background: linear-gradient(135deg, #ec4899 0%, #db2777 100%);
color: white;
}
&.bw {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
color: white;
}
&.rtl {
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
}
}
.comic-web-link {
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.comic-external-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--primary-color);
text-decoration: none;
font-size: 0.9rem;
transition: color 0.2s ease;
i {
font-size: 0.85rem;
}
&:hover {
color: var(--primary-hover-color);
text-decoration: underline;
}
}
.comic-notes {
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.comic-notes-label {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.375rem;
i {
font-size: 0.9rem;
color: var(--primary-color);
}
}
.comic-notes-content {
font-size: 0.9rem;
color: var(--text-color-secondary);
line-height: 1.5;
margin: 0;
white-space: pre-wrap;
}
// Audiobook Metadata Section Styles
.audiobook-metadata-section {
background: var(--card-background);
border: 1px solid var(--border-color);
border-radius: 0.75rem;
overflow: hidden;
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.audiobook-metadata-content {
padding: 0.875rem;
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
opacity: 1;
&.collapsed {
max-height: 0;
padding: 0 0.875rem;
opacity: 0;
}
}
.audiobook-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.audiobook-info-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.audiobook-info-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.audiobook-info-value {
font-size: 0.95rem;
color: var(--text-color);
font-weight: 500;
}
.audiobook-link {
color: cornflowerblue;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: dodgerblue;
text-decoration: underline;
}
}
.audiobook-properties {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color);
}
.audiobook-property-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
i {
font-size: 0.75rem;
}
&.unabridged {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
}
&.abridged {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
}
}

View File

@@ -5,7 +5,7 @@ import {combineLatest, Observable} from 'rxjs';
import {BookService} from '../../../../book/service/book.service';
import {Rating, RatingRateEvent} from 'primeng/rating';
import {FormsModule} from '@angular/forms';
import {Book, BookFile, BookMetadata, BookRecommendation, BookType, FileInfo, ReadStatus} from '../../../../book/model/book.model';
import {Book, BookFile, BookMetadata, BookRecommendation, BookType, ComicMetadata, FileInfo, ReadStatus} from '../../../../book/model/book.model';
import {UrlHelperService} from '../../../../../shared/service/url-helper.service';
import {UserService} from '../../../../settings/user-management/user.service';
import {SplitButton} from 'primeng/splitbutton';
@@ -30,7 +30,6 @@ import {TagColor, TagComponent} from '../../../../../shared/components/tag/tag.c
import {TaskHelperService} from '../../../../settings/task-management/task-helper.service';
import {AGE_RATING_OPTIONS, CONTENT_RATING_LABELS, fileSizeRanges, matchScoreRanges, pageCountRanges} from '../../../../book/components/book-browser/book-filter/book-filter.config';
import {BookNavigationService} from '../../../../book/service/book-navigation.service';
import {Divider} from 'primeng/divider';
import {BookMetadataHostService} from '../../../../../shared/service/book-metadata-host.service';
import {AppSettingsService} from '../../../../../shared/service/app-settings.service';
import {DeleteBookFileEvent, DeleteSupplementaryFileEvent, DownloadAdditionalFileEvent, DownloadAllFilesEvent, DownloadEvent, MetadataTabsComponent, ReadEvent} from './metadata-tabs/metadata-tabs.component';
@@ -41,7 +40,7 @@ import {DeleteBookFileEvent, DeleteSupplementaryFileEvent, DownloadAdditionalFil
standalone: true,
templateUrl: './metadata-viewer.component.html',
styleUrl: './metadata-viewer.component.scss',
imports: [Button, AsyncPipe, Rating, FormsModule, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, DatePicker, ProgressSpinner, TieredMenu, Image, TagComponent, Divider, MetadataTabsComponent]
imports: [Button, AsyncPipe, Rating, FormsModule, SplitButton, NgClass, Tooltip, DecimalPipe, Editor, ProgressBar, Menu, DatePicker, ProgressSpinner, TieredMenu, Image, TagComponent, MetadataTabsComponent]
})
export class MetadataViewerComponent implements OnInit, OnChanges {
@Input() book$!: Observable<Book | null>;
@@ -68,6 +67,8 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
downloadMenuItems$!: Observable<MenuItem[]>;
bookInSeries: Book[] = [];
isExpanded = false;
isComicSectionExpanded = true;
isAudiobookSectionExpanded = true;
showFilePath = false;
isAutoFetching = false;
private metadataCenterViewMode: 'route' | 'dialog' = 'route';
@@ -1246,4 +1247,122 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
? this.urlHelper.getAudiobookCoverUrl(book.id, book.metadata?.audiobookCoverUpdatedOn)
: this.urlHelper.getCoverUrl(book.id, book.metadata?.coverUpdatedOn);
}
// Comic metadata helpers
isComicBook(book: Book): boolean {
return book?.primaryFile?.bookType === 'CBX';
}
hasComicMetadata(book: Book): boolean {
const comic = book?.metadata?.comicMetadata;
if (!comic) return false;
return !!(
comic.issueNumber ||
comic.volumeName ||
comic.storyArc ||
comic.characters?.length ||
comic.teams?.length ||
comic.locations?.length ||
comic.pencillers?.length ||
comic.inkers?.length ||
comic.colorists?.length ||
comic.letterers?.length ||
comic.coverArtists?.length ||
comic.editors?.length ||
comic.manga ||
comic.blackAndWhite ||
comic.webLink ||
comic.notes
);
}
hasAnyCreators(comic: ComicMetadata): boolean {
return !!(
comic.pencillers?.length ||
comic.inkers?.length ||
comic.colorists?.length ||
comic.letterers?.length ||
comic.coverArtists?.length ||
comic.editors?.length
);
}
formatWebLink(url: string): string {
if (!url) return '';
try {
const parsed = new URL(url);
const path = parsed.pathname.length > 30
? parsed.pathname.substring(0, 30) + '...'
: parsed.pathname;
return parsed.hostname + path;
} catch {
return url.length > 50 ? url.substring(0, 50) + '...' : url;
}
}
goToCharacter(character: string): void {
this.handleMetadataClick('comicCharacter', character);
}
goToTeam(team: string): void {
this.handleMetadataClick('comicTeam', team);
}
goToLocation(location: string): void {
this.handleMetadataClick('comicLocation', location);
}
goToCreator(name: string, role: string): void {
this.handleMetadataClick('comicCreator', `${name}:${role}`);
}
// Audiobook metadata helpers
isAudiobook(book: Book): boolean {
return book?.primaryFile?.bookType === 'AUDIOBOOK';
}
hasAudiobookMetadata(book: Book): boolean {
const audio = book?.metadata?.audiobookMetadata;
if (!audio) return false;
return !!(
audio.durationSeconds ||
audio.bitrate ||
audio.sampleRate ||
audio.channels ||
audio.codec ||
audio.chapterCount ||
book.metadata?.narrator ||
book.metadata?.abridged != null
);
}
formatDuration(seconds: number): string {
if (!seconds) return '-';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
formatSampleRate(sampleRate: number): string {
if (!sampleRate) return '-';
return `${(sampleRate / 1000).toFixed(1)} kHz`;
}
getChannelLabel(channels: number): string {
switch (channels) {
case 1:
return 'Mono';
case 2:
return 'Stereo';
default:
return `${channels} channels`;
}
}
goToNarrator(narrator: string): void {
this.handleMetadataClick('narrator', narrator);
}
}