mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -33,6 +33,7 @@ public class BookMetadata {
|
||||
private Boolean abridged;
|
||||
|
||||
private AudiobookMetadata audiobookMetadata;
|
||||
private ComicMetadata comicMetadata;
|
||||
|
||||
private String asin;
|
||||
private Double amazonRating;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<>();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.booklore.model.enums;
|
||||
|
||||
public enum ComicCreatorRole {
|
||||
PENCILLER,
|
||||
INKER,
|
||||
COLORIST,
|
||||
LETTERER,
|
||||
COVER_ARTIST,
|
||||
EDITOR
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user