mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(sidecar): add sidecar JSON metadata file support (#2657)
Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package org.booklore.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.booklore.config.security.annotation.CheckBookAccess;
|
||||
import org.booklore.model.dto.sidecar.SidecarMetadata;
|
||||
import org.booklore.model.enums.SidecarSyncStatus;
|
||||
import org.booklore.service.metadata.sidecar.SidecarService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Tag(name = "Sidecar Metadata", description = "Endpoints for managing sidecar JSON metadata files")
|
||||
@RequestMapping("/api/v1")
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
public class SidecarController {
|
||||
|
||||
private final SidecarService sidecarService;
|
||||
|
||||
@Operation(summary = "Get sidecar content", description = "Get the content of the sidecar JSON file for a book")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Sidecar content returned successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "Book or sidecar file not found")
|
||||
})
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
@GetMapping("/books/{bookId}/sidecar")
|
||||
public ResponseEntity<SidecarMetadata> getSidecarContent(@Parameter(description = "Book ID") @PathVariable Long bookId) {
|
||||
Optional<SidecarMetadata> sidecar = sidecarService.getSidecarContent(bookId);
|
||||
return sidecar.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@Operation(summary = "Get sidecar sync status", description = "Get the synchronization status between database and sidecar file")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Sync status returned successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "Book not found")
|
||||
})
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
@GetMapping("/books/{bookId}/sidecar/status")
|
||||
public ResponseEntity<Map<String, SidecarSyncStatus>> getSyncStatus(@Parameter(description = "Book ID") @PathVariable Long bookId) {
|
||||
SidecarSyncStatus status = sidecarService.getSyncStatus(bookId);
|
||||
return ResponseEntity.ok(Map.of("status", status));
|
||||
}
|
||||
|
||||
@Operation(summary = "Export metadata to sidecar", description = "Export book metadata to a sidecar JSON file")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Metadata exported successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "Book not found")
|
||||
})
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
@PostMapping("/books/{bookId}/sidecar/export")
|
||||
public ResponseEntity<Map<String, String>> exportToSidecar(@Parameter(description = "Book ID") @PathVariable Long bookId) {
|
||||
sidecarService.exportToSidecar(bookId);
|
||||
return ResponseEntity.ok(Map.of("message", "Sidecar metadata exported successfully"));
|
||||
}
|
||||
|
||||
@Operation(summary = "Import metadata from sidecar", description = "Import book metadata from a sidecar JSON file")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Metadata imported successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "Book or sidecar file not found")
|
||||
})
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
@PostMapping("/books/{bookId}/sidecar/import")
|
||||
public ResponseEntity<Map<String, String>> importFromSidecar(@Parameter(description = "Book ID") @PathVariable Long bookId) {
|
||||
sidecarService.importFromSidecar(bookId);
|
||||
return ResponseEntity.ok(Map.of("message", "Sidecar metadata imported successfully"));
|
||||
}
|
||||
|
||||
@Operation(summary = "Bulk export sidecar for library", description = "Generate sidecar JSON files for all books in a library")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Bulk export completed"),
|
||||
@ApiResponse(responseCode = "404", description = "Library not found")
|
||||
})
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
@PostMapping("/libraries/{libraryId}/sidecar/export-all")
|
||||
public ResponseEntity<Map<String, Object>> bulkExport(@Parameter(description = "Library ID") @PathVariable Long libraryId) {
|
||||
int exported = sidecarService.bulkExport(libraryId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Bulk export completed",
|
||||
"exported", exported
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(summary = "Bulk import sidecar for library", description = "Import metadata from all sidecar JSON files in a library")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Bulk import completed"),
|
||||
@ApiResponse(responseCode = "404", description = "Library not found")
|
||||
})
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
@PostMapping("/libraries/{libraryId}/sidecar/import-all")
|
||||
public ResponseEntity<Map<String, Object>> bulkImport(@Parameter(description = "Library ID") @PathVariable Long libraryId) {
|
||||
int imported = sidecarService.bulkImport(libraryId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Bulk import completed",
|
||||
"imported", imported
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.List;
|
||||
@@ -9,6 +10,7 @@ import java.util.List;
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AudiobookMetadata {
|
||||
private Long durationSeconds;
|
||||
private Integer bitrate;
|
||||
@@ -23,6 +25,7 @@ public class AudiobookMetadata {
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ChapterInfo {
|
||||
private Integer index;
|
||||
private String title;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.Set;
|
||||
@@ -9,6 +10,7 @@ import java.util.Set;
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ComicMetadata {
|
||||
private String issueNumber;
|
||||
private String volumeName;
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.booklore.model.dto;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.IconType;
|
||||
import org.booklore.model.enums.LibraryOrganizationMode;
|
||||
import org.booklore.model.enums.MetadataSource;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -24,5 +25,6 @@ public class Library {
|
||||
private List<BookFileType> formatPriority;
|
||||
private List<BookFileType> allowedFormats;
|
||||
private LibraryOrganizationMode organizationMode;
|
||||
private MetadataSource metadataSource;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.booklore.model.dto.request;
|
||||
import org.booklore.model.dto.LibraryPath;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.IconType;
|
||||
import org.booklore.model.enums.MetadataSource;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
@@ -28,4 +29,5 @@ public class CreateLibraryRequest {
|
||||
private boolean watch;
|
||||
private List<BookFileType> formatPriority;
|
||||
private List<BookFileType> allowedFormats;
|
||||
private MetadataSource metadataSource;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public class MetadataPersistenceSettings {
|
||||
private SaveToOriginalFile saveToOriginalFile;
|
||||
private boolean convertCbrCb7ToCbz;
|
||||
private boolean moveFilesToLibraryPattern;
|
||||
private SidecarSettings sidecarSettings;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.booklore.model.dto.settings;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SidecarSettings {
|
||||
private boolean enabled;
|
||||
private boolean writeOnUpdate;
|
||||
private boolean writeOnScan;
|
||||
private boolean includeCoverFile;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarBookMetadata {
|
||||
private String title;
|
||||
private String subtitle;
|
||||
private Set<String> authors;
|
||||
private String publisher;
|
||||
private LocalDate publishedDate;
|
||||
private String description;
|
||||
private String isbn10;
|
||||
private String isbn13;
|
||||
private String language;
|
||||
private Integer pageCount;
|
||||
private Set<String> categories;
|
||||
private Set<String> moods;
|
||||
private Set<String> tags;
|
||||
private SidecarSeries series;
|
||||
private SidecarIdentifiers identifiers;
|
||||
private SidecarRatings ratings;
|
||||
private Integer ageRating;
|
||||
private String contentRating;
|
||||
private String narrator;
|
||||
private Boolean abridged;
|
||||
private ComicMetadata comicMetadata;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarCoverInfo {
|
||||
private String source;
|
||||
private String path;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarIdentifiers {
|
||||
private String asin;
|
||||
private String goodreadsId;
|
||||
private String googleId;
|
||||
private String hardcoverId;
|
||||
private String comicvineId;
|
||||
private String lubimyczytacId;
|
||||
private String ranobedbId;
|
||||
private String audibleId;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarMetadata {
|
||||
@Builder.Default
|
||||
private String version = "1.0";
|
||||
private Instant generatedAt;
|
||||
@Builder.Default
|
||||
private String generatedBy = "booklore";
|
||||
private SidecarBookMetadata metadata;
|
||||
private SidecarCoverInfo cover;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarRating {
|
||||
private Double average;
|
||||
private Integer count;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarRatings {
|
||||
private SidecarRating amazon;
|
||||
private SidecarRating goodreads;
|
||||
private SidecarRating hardcover;
|
||||
private SidecarRating lubimyczytac;
|
||||
private SidecarRating ranobedb;
|
||||
private SidecarRating audible;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarSeries {
|
||||
private String name;
|
||||
private Float number;
|
||||
private Integer total;
|
||||
}
|
||||
@@ -331,7 +331,7 @@ public class BookMetadataEntity {
|
||||
@JsonIgnore
|
||||
private BookEntity book;
|
||||
|
||||
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OneToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "book_id", referencedColumnName = "book_id", insertable = false, updatable = false)
|
||||
private ComicMetadataEntity comicMetadata;
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ public class ComicMetadataEntity {
|
||||
@Builder.Default
|
||||
private Set<ComicLocationEntity> locations = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "comicMetadata", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OneToMany(mappedBy = "comicMetadata", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
|
||||
@Fetch(FetchMode.SUBSELECT)
|
||||
@Builder.Default
|
||||
private Set<ComicCreatorMappingEntity> creatorMappings = new HashSet<>();
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.booklore.model.dto.Sort;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.IconType;
|
||||
import org.booklore.model.enums.LibraryOrganizationMode;
|
||||
import org.booklore.model.enums.MetadataSource;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@@ -64,4 +65,9 @@ public class LibraryEntity {
|
||||
@Builder.Default
|
||||
private LibraryOrganizationMode organizationMode = LibraryOrganizationMode.AUTO_DETECT;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "metadata_source")
|
||||
@Builder.Default
|
||||
private MetadataSource metadataSource = MetadataSource.EMBEDDED;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.booklore.model.enums;
|
||||
|
||||
public enum MetadataSource {
|
||||
EMBEDDED,
|
||||
SIDECAR,
|
||||
PREFER_SIDECAR,
|
||||
PREFER_EMBEDDED,
|
||||
NONE
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.booklore.model.enums;
|
||||
|
||||
public enum SidecarSyncStatus {
|
||||
IN_SYNC,
|
||||
OUTDATED,
|
||||
MISSING,
|
||||
CONFLICT,
|
||||
NOT_APPLICABLE
|
||||
}
|
||||
@@ -62,6 +62,10 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByLibraryId(@Param("libraryId") Long libraryId);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllByLibraryIdWithFiles(@Param("libraryId") Long libraryId);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.booklore.model.entity.UserBookProgressEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.repository.*;
|
||||
import org.booklore.repository.BookFileRepository;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.booklore.service.progress.ReadingProgressService;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -62,6 +63,7 @@ public class BookService {
|
||||
private final MonitoringRegistrationService monitoringRegistrationService;
|
||||
private final BookUpdateService bookUpdateService;
|
||||
private final EbookViewerPreferenceRepository ebookViewerPreferencesRepository;
|
||||
private final SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
|
||||
public List<Book> getBookDTOs(boolean includeDescription) {
|
||||
@@ -346,6 +348,12 @@ public class BookService {
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
deleteEmptyParentDirsUpToLibraryFolders(fullFilePath.getParent(), libraryRoots);
|
||||
|
||||
try {
|
||||
sidecarMetadataWriter.deleteSidecarFiles(fullFilePath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to delete sidecar files for: {}", fullFilePath, e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete book file: {}", fullFilePath, e);
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.booklore.repository.BookAdditionalFileRepository;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.LibraryRepository;
|
||||
import org.booklore.service.NotificationService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
@@ -44,6 +45,7 @@ public class FileMoveService {
|
||||
private final NotificationService notificationService;
|
||||
private final EntityManager entityManager;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
private final SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
|
||||
public void bulkMoveFiles(FileMoveRequest request) {
|
||||
@@ -220,6 +222,12 @@ public class FileMoveService {
|
||||
fileMoveHelper.deleteEmptyParentDirsUpToLibraryFolders(sourceParent, libraryRoots);
|
||||
}
|
||||
|
||||
try {
|
||||
sidecarMetadataWriter.moveSidecarFiles(currentPrimaryFilePath, newFilePath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to move sidecar files for book ID {}: {}", bookId, e.getMessage());
|
||||
}
|
||||
|
||||
entityManager.clear();
|
||||
|
||||
BookEntity fresh = bookRepository.findById(bookId).orElseThrow();
|
||||
@@ -373,6 +381,12 @@ public class FileMoveService {
|
||||
fileMoveHelper.deleteEmptyParentDirsUpToLibraryFolders(sourceParent, libraryRoots);
|
||||
}
|
||||
|
||||
try {
|
||||
sidecarMetadataWriter.moveSidecarFiles(currentPrimaryFilePath, expectedPrimaryFilePath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to move sidecar files for book ID {}: {}", bookEntity.getId(), e.getMessage());
|
||||
}
|
||||
|
||||
if (isLibraryMonitoredWhenCalled) {
|
||||
// Ensure any file system events from the move and cleanup are drained/ignored while we are still unregistered
|
||||
sleep(EVENT_DRAIN_TIMEOUT_MS);
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.file.FileFingerprint;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.FileService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
@@ -27,6 +28,7 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
|
||||
protected final BookMapper bookMapper;
|
||||
protected final MetadataMatchService metadataMatchService;
|
||||
protected final FileService fileService;
|
||||
protected final SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
|
||||
protected AbstractFileProcessor(BookRepository bookRepository,
|
||||
@@ -34,13 +36,15 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
|
||||
BookCreatorService bookCreatorService,
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService) {
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter) {
|
||||
this.bookRepository = bookRepository;
|
||||
this.bookAdditionalFileRepository = bookAdditionalFileRepository;
|
||||
this.bookCreatorService = bookCreatorService;
|
||||
this.bookMapper = bookMapper;
|
||||
this.metadataMatchService = metadataMatchService;
|
||||
this.fileService = fileService;
|
||||
this.sidecarMetadataWriter = sidecarMetadataWriter;
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
@@ -59,6 +63,15 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
|
||||
entity.getPrimaryBookFile().setCurrentHash(hash);
|
||||
entity.setMetadataMatchScore(metadataMatchService.calculateMatchScore(entity));
|
||||
bookCreatorService.saveConnections(entity);
|
||||
|
||||
if (sidecarMetadataWriter.isWriteOnScanEnabled()) {
|
||||
try {
|
||||
sidecarMetadataWriter.writeSidecarMetadata(entity);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to write sidecar metadata for book ID {}: {}", entity.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return bookMapper.toBook(entity);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.AudiobookMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -42,8 +43,9 @@ public class AudiobookProcessor extends AbstractFileProcessor implements BookFil
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
AudiobookMetadataExtractor audiobookMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.audiobookMetadataExtractor = audiobookMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.Azw3MetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -38,8 +39,9 @@ public class Azw3Processor extends AbstractFileProcessor implements BookFileProc
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
Azw3MetadataExtractor azw3MetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.azw3MetadataExtractor = azw3MetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.CbxMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.ArchiveUtils;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -53,8 +54,9 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
CbxMetadataExtractor cbxMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.cbxMetadataExtractor = cbxMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.EpubMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -40,8 +41,9 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
EpubMetadataExtractor epubMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.epubMetadataExtractor = epubMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.Fb2MetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -38,8 +39,9 @@ public class Fb2Processor extends AbstractFileProcessor implements BookFileProce
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
Fb2MetadataExtractor fb2MetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.fb2MetadataExtractor = fb2MetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.MobiMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -38,8 +39,9 @@ public class MobiProcessor extends AbstractFileProcessor implements BookFileProc
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
MobiMetadataExtractor mobiMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.mobiMetadataExtractor = mobiMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.PdfMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -42,8 +43,9 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
PdfMetadataExtractor pdfMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.pdfMetadataExtractor = pdfMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,9 @@ public class LibraryService {
|
||||
library.setWatch(request.isWatch());
|
||||
library.setFormatPriority(request.getFormatPriority());
|
||||
library.setAllowedFormats(request.getAllowedFormats());
|
||||
if (request.getMetadataSource() != null) {
|
||||
library.setMetadataSource(request.getMetadataSource());
|
||||
}
|
||||
|
||||
Set<String> currentPaths = library.getLibraryPaths().stream()
|
||||
.map(LibraryPathEntity::getPath)
|
||||
@@ -173,6 +176,7 @@ public class LibraryService {
|
||||
.watch(request.isWatch())
|
||||
.formatPriority(request.getFormatPriority())
|
||||
.allowedFormats(request.getAllowedFormats())
|
||||
.metadataSource(request.getMetadataSource())
|
||||
.users(List.of(user.get()))
|
||||
.build();
|
||||
|
||||
@@ -251,13 +255,13 @@ public class LibraryService {
|
||||
return libraries.stream().map(libraryMapper::toLibrary).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteLibrary(long id) {
|
||||
LibraryEntity library = libraryRepository.findById(id).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(id));
|
||||
library.getLibraryPaths().forEach(libraryPath -> {
|
||||
Path path = Paths.get(libraryPath.getPath());
|
||||
monitoringService.unregisterLibrary(id);
|
||||
});
|
||||
Set<Long> bookIds = library.getBookEntities().stream().map(BookEntity::getId).collect(Collectors.toSet());
|
||||
if (!libraryRepository.existsById(id)) {
|
||||
throw ApiError.LIBRARY_NOT_FOUND.createException(id);
|
||||
}
|
||||
monitoringService.unregisterLibrary(id);
|
||||
Set<Long> bookIds = bookRepository.findBookIdsByLibraryId(id);
|
||||
fileService.deleteBookCovers(bookIds);
|
||||
libraryRepository.deleteById(id);
|
||||
log.info("Library deleted successfully: {}", id);
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.booklore.repository.*;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.service.file.FileFingerprint;
|
||||
import org.booklore.service.file.FileMoveService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.metadata.writer.MetadataWriterFactory;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -56,6 +57,7 @@ public class BookMetadataUpdater {
|
||||
private final MetadataWriterFactory metadataWriterFactory;
|
||||
private final BookReviewUpdateService bookReviewUpdateService;
|
||||
private final FileMoveService fileMoveService;
|
||||
private final SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
@Transactional
|
||||
public void setBookMetadata(MetadataUpdateContext context) {
|
||||
@@ -138,6 +140,14 @@ public class BookMetadataUpdater {
|
||||
});
|
||||
}
|
||||
|
||||
if (sidecarMetadataWriter.isWriteOnUpdateEnabled()) {
|
||||
try {
|
||||
sidecarMetadataWriter.writeSidecarMetadata(bookEntity);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to write sidecar metadata for book ID {}: {}", bookId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
boolean moveFilesToLibraryPattern = settings.isMoveFilesToLibraryPattern();
|
||||
if (moveFilesToLibraryPattern && primaryFile != null) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.model.dto.sidecar.*;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SidecarMetadataMapper {
|
||||
|
||||
public SidecarMetadata toSidecarMetadata(BookMetadataEntity entity, String coverFileName) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SidecarBookMetadata bookMetadata = SidecarBookMetadata.builder()
|
||||
.title(entity.getTitle())
|
||||
.subtitle(entity.getSubtitle())
|
||||
.authors(extractNames(entity.getAuthors()))
|
||||
.publisher(entity.getPublisher())
|
||||
.publishedDate(entity.getPublishedDate())
|
||||
.description(entity.getDescription())
|
||||
.isbn10(entity.getIsbn10())
|
||||
.isbn13(entity.getIsbn13())
|
||||
.language(entity.getLanguage())
|
||||
.pageCount(entity.getPageCount())
|
||||
.categories(extractCategoryNames(entity.getCategories()))
|
||||
.moods(extractMoodNames(entity.getMoods()))
|
||||
.tags(extractTagNames(entity.getTags()))
|
||||
.series(buildSeries(entity))
|
||||
.identifiers(buildIdentifiers(entity))
|
||||
.ratings(buildRatings(entity))
|
||||
.ageRating(entity.getAgeRating())
|
||||
.contentRating(entity.getContentRating())
|
||||
.narrator(entity.getNarrator())
|
||||
.abridged(entity.getAbridged())
|
||||
.comicMetadata(buildComicMetadata(entity.getComicMetadata()))
|
||||
.build();
|
||||
|
||||
SidecarCoverInfo coverInfo = null;
|
||||
if (StringUtils.hasText(coverFileName)) {
|
||||
coverInfo = SidecarCoverInfo.builder()
|
||||
.source("external")
|
||||
.path(coverFileName)
|
||||
.build();
|
||||
}
|
||||
|
||||
return SidecarMetadata.builder()
|
||||
.version("1.0")
|
||||
.generatedAt(Instant.now())
|
||||
.generatedBy("booklore")
|
||||
.metadata(bookMetadata)
|
||||
.cover(coverInfo)
|
||||
.build();
|
||||
}
|
||||
|
||||
public BookMetadata toBookMetadata(SidecarMetadata sidecar) {
|
||||
if (sidecar == null || sidecar.getMetadata() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SidecarBookMetadata m = sidecar.getMetadata();
|
||||
|
||||
BookMetadata.BookMetadataBuilder builder = BookMetadata.builder()
|
||||
.title(m.getTitle())
|
||||
.subtitle(m.getSubtitle())
|
||||
.authors(m.getAuthors())
|
||||
.publisher(m.getPublisher())
|
||||
.publishedDate(m.getPublishedDate())
|
||||
.description(m.getDescription())
|
||||
.isbn10(m.getIsbn10())
|
||||
.isbn13(m.getIsbn13())
|
||||
.language(m.getLanguage())
|
||||
.pageCount(m.getPageCount())
|
||||
.categories(m.getCategories())
|
||||
.moods(m.getMoods())
|
||||
.tags(m.getTags())
|
||||
.ageRating(m.getAgeRating())
|
||||
.contentRating(m.getContentRating())
|
||||
.narrator(m.getNarrator())
|
||||
.abridged(m.getAbridged())
|
||||
.comicMetadata(m.getComicMetadata());
|
||||
|
||||
if (m.getSeries() != null) {
|
||||
builder.seriesName(m.getSeries().getName())
|
||||
.seriesNumber(m.getSeries().getNumber())
|
||||
.seriesTotal(m.getSeries().getTotal());
|
||||
}
|
||||
|
||||
if (m.getIdentifiers() != null) {
|
||||
SidecarIdentifiers ids = m.getIdentifiers();
|
||||
builder.asin(ids.getAsin())
|
||||
.goodreadsId(ids.getGoodreadsId())
|
||||
.googleId(ids.getGoogleId())
|
||||
.hardcoverId(ids.getHardcoverId())
|
||||
.comicvineId(ids.getComicvineId())
|
||||
.lubimyczytacId(ids.getLubimyczytacId())
|
||||
.ranobedbId(ids.getRanobedbId())
|
||||
.audibleId(ids.getAudibleId());
|
||||
}
|
||||
|
||||
if (m.getRatings() != null) {
|
||||
SidecarRatings r = m.getRatings();
|
||||
if (r.getAmazon() != null) {
|
||||
builder.amazonRating(r.getAmazon().getAverage())
|
||||
.amazonReviewCount(r.getAmazon().getCount());
|
||||
}
|
||||
if (r.getGoodreads() != null) {
|
||||
builder.goodreadsRating(r.getGoodreads().getAverage())
|
||||
.goodreadsReviewCount(r.getGoodreads().getCount());
|
||||
}
|
||||
if (r.getHardcover() != null) {
|
||||
builder.hardcoverRating(r.getHardcover().getAverage())
|
||||
.hardcoverReviewCount(r.getHardcover().getCount());
|
||||
}
|
||||
if (r.getLubimyczytac() != null) {
|
||||
builder.lubimyczytacRating(r.getLubimyczytac().getAverage());
|
||||
}
|
||||
if (r.getRanobedb() != null) {
|
||||
builder.ranobedbRating(r.getRanobedb().getAverage());
|
||||
}
|
||||
if (r.getAudible() != null) {
|
||||
builder.audibleRating(r.getAudible().getAverage())
|
||||
.audibleReviewCount(r.getAudible().getCount());
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public String getCoverFileName(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return baseName + ".cover.jpg";
|
||||
}
|
||||
|
||||
private Set<String> extractNames(Set<AuthorEntity> entities) {
|
||||
if (entities == null) return null;
|
||||
return entities.stream()
|
||||
.map(AuthorEntity::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Set<String> extractCategoryNames(Set<CategoryEntity> entities) {
|
||||
if (entities == null) return null;
|
||||
return entities.stream()
|
||||
.map(CategoryEntity::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Set<String> extractMoodNames(Set<MoodEntity> entities) {
|
||||
if (entities == null) return null;
|
||||
return entities.stream()
|
||||
.map(MoodEntity::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Set<String> extractTagNames(Set<TagEntity> entities) {
|
||||
if (entities == null) return null;
|
||||
return entities.stream()
|
||||
.map(TagEntity::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private SidecarSeries buildSeries(BookMetadataEntity entity) {
|
||||
if (entity.getSeriesName() == null && entity.getSeriesNumber() == null && entity.getSeriesTotal() == null) {
|
||||
return null;
|
||||
}
|
||||
return SidecarSeries.builder()
|
||||
.name(entity.getSeriesName())
|
||||
.number(entity.getSeriesNumber())
|
||||
.total(entity.getSeriesTotal())
|
||||
.build();
|
||||
}
|
||||
|
||||
private SidecarIdentifiers buildIdentifiers(BookMetadataEntity entity) {
|
||||
return SidecarIdentifiers.builder()
|
||||
.asin(entity.getAsin())
|
||||
.goodreadsId(entity.getGoodreadsId())
|
||||
.googleId(entity.getGoogleId())
|
||||
.hardcoverId(entity.getHardcoverId())
|
||||
.comicvineId(entity.getComicvineId())
|
||||
.lubimyczytacId(entity.getLubimyczytacId())
|
||||
.ranobedbId(entity.getRanobedbId())
|
||||
.audibleId(entity.getAudibleId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private SidecarRatings buildRatings(BookMetadataEntity entity) {
|
||||
return SidecarRatings.builder()
|
||||
.amazon(buildRating(entity.getAmazonRating(), entity.getAmazonReviewCount()))
|
||||
.goodreads(buildRating(entity.getGoodreadsRating(), entity.getGoodreadsReviewCount()))
|
||||
.hardcover(buildRating(entity.getHardcoverRating(), entity.getHardcoverReviewCount()))
|
||||
.lubimyczytac(buildRating(entity.getLubimyczytacRating(), null))
|
||||
.ranobedb(buildRating(entity.getRanobedbRating(), null))
|
||||
.audible(buildRating(entity.getAudibleRating(), entity.getAudibleReviewCount()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private SidecarRating buildRating(Double average, Integer count) {
|
||||
if (average == null && count == null) {
|
||||
return null;
|
||||
}
|
||||
return SidecarRating.builder()
|
||||
.average(average)
|
||||
.count(count)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ComicMetadata buildComicMetadata(ComicMetadataEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
return ComicMetadata.builder()
|
||||
.issueNumber(entity.getIssueNumber())
|
||||
.volumeName(entity.getVolumeName())
|
||||
.volumeNumber(entity.getVolumeNumber())
|
||||
.storyArc(entity.getStoryArc())
|
||||
.storyArcNumber(entity.getStoryArcNumber())
|
||||
.alternateSeries(entity.getAlternateSeries())
|
||||
.alternateIssue(entity.getAlternateIssue())
|
||||
.imprint(entity.getImprint())
|
||||
.format(entity.getFormat())
|
||||
.blackAndWhite(entity.getBlackAndWhite())
|
||||
.manga(entity.getManga())
|
||||
.readingDirection(entity.getReadingDirection())
|
||||
.webLink(entity.getWebLink())
|
||||
.notes(entity.getNotes())
|
||||
.characters(entity.getCharacters() != null ?
|
||||
entity.getCharacters().stream().map(ComicCharacterEntity::getName).collect(Collectors.toSet()) : null)
|
||||
.teams(entity.getTeams() != null ?
|
||||
entity.getTeams().stream().map(ComicTeamEntity::getName).collect(Collectors.toSet()) : null)
|
||||
.locations(entity.getLocations() != null ?
|
||||
entity.getLocations().stream().map(ComicLocationEntity::getName).collect(Collectors.toSet()) : null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.sidecar.SidecarMetadata;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.enums.SidecarSyncStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SidecarMetadataReader {
|
||||
|
||||
private final SidecarMetadataMapper mapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SidecarMetadataReader(SidecarMetadataMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
}
|
||||
|
||||
public Optional<SidecarMetadata> readSidecarMetadata(Path bookPath) {
|
||||
if (bookPath == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Path sidecarPath = getSidecarPath(bookPath);
|
||||
if (!Files.exists(sidecarPath)) {
|
||||
log.debug("No sidecar file found at: {}", sidecarPath);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
String json = Files.readString(sidecarPath);
|
||||
SidecarMetadata metadata = objectMapper.readValue(json, SidecarMetadata.class);
|
||||
log.debug("Read sidecar metadata from: {}", sidecarPath);
|
||||
return Optional.of(metadata);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read sidecar metadata from {}: {}", sidecarPath, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] readSidecarCover(Path bookPath) {
|
||||
if (bookPath == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Path coverPath = getCoverPath(bookPath);
|
||||
if (!Files.exists(coverPath)) {
|
||||
log.debug("No sidecar cover file found at: {}", coverPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Files.readAllBytes(coverPath);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read sidecar cover from {}: {}", coverPath, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public SidecarSyncStatus getSyncStatus(BookEntity book) {
|
||||
if (book == null || book.getPrimaryBookFile() == null) {
|
||||
return SidecarSyncStatus.NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null) {
|
||||
return SidecarSyncStatus.NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
if (!sidecarExists(bookPath)) {
|
||||
return SidecarSyncStatus.MISSING;
|
||||
}
|
||||
|
||||
Optional<SidecarMetadata> sidecarOpt = readSidecarMetadata(bookPath);
|
||||
if (sidecarOpt.isEmpty()) {
|
||||
return SidecarSyncStatus.MISSING;
|
||||
}
|
||||
|
||||
SidecarMetadata sidecar = sidecarOpt.get();
|
||||
BookMetadataEntity dbMetadata = book.getMetadata();
|
||||
|
||||
if (dbMetadata == null) {
|
||||
return SidecarSyncStatus.CONFLICT;
|
||||
}
|
||||
|
||||
// Check content first - if metadata matches, it's in sync regardless of timestamps
|
||||
if (!isMetadataDifferent(sidecar, dbMetadata)) {
|
||||
return SidecarSyncStatus.IN_SYNC;
|
||||
}
|
||||
|
||||
// Content differs - use timestamps to determine which is newer
|
||||
if (sidecar.getGeneratedAt() != null && book.getMetadataUpdatedAt() != null) {
|
||||
if (book.getMetadataUpdatedAt().isAfter(sidecar.getGeneratedAt())) {
|
||||
return SidecarSyncStatus.OUTDATED; // DB is newer, sidecar needs update
|
||||
}
|
||||
}
|
||||
|
||||
return SidecarSyncStatus.CONFLICT; // Sidecar is newer or timestamps unavailable
|
||||
}
|
||||
|
||||
public boolean sidecarExists(Path bookPath) {
|
||||
if (bookPath == null) {
|
||||
return false;
|
||||
}
|
||||
Path sidecarPath = getSidecarPath(bookPath);
|
||||
return Files.exists(sidecarPath);
|
||||
}
|
||||
|
||||
public Path getSidecarPath(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return bookPath.getParent().resolve(baseName + ".metadata.json");
|
||||
}
|
||||
|
||||
public Path getCoverPath(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return bookPath.getParent().resolve(baseName + ".cover.jpg");
|
||||
}
|
||||
|
||||
private boolean isMetadataDifferent(SidecarMetadata sidecar, BookMetadataEntity db) {
|
||||
if (sidecar.getMetadata() == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var sm = sidecar.getMetadata();
|
||||
|
||||
if (!nullSafeEquals(sm.getTitle(), db.getTitle())) return true;
|
||||
if (!nullSafeEquals(sm.getSubtitle(), db.getSubtitle())) return true;
|
||||
if (!nullSafeEquals(sm.getPublisher(), db.getPublisher())) return true;
|
||||
if (!nullSafeEquals(sm.getDescription(), db.getDescription())) return true;
|
||||
if (!nullSafeEquals(sm.getIsbn10(), db.getIsbn10())) return true;
|
||||
if (!nullSafeEquals(sm.getIsbn13(), db.getIsbn13())) return true;
|
||||
if (!nullSafeEquals(sm.getLanguage(), db.getLanguage())) return true;
|
||||
if (!nullSafeEquals(sm.getPageCount(), db.getPageCount())) return true;
|
||||
|
||||
if (sm.getSeries() != null) {
|
||||
if (!nullSafeEquals(sm.getSeries().getName(), db.getSeriesName())) return true;
|
||||
if (!nullSafeEquals(sm.getSeries().getNumber(), db.getSeriesNumber())) return true;
|
||||
if (!nullSafeEquals(sm.getSeries().getTotal(), db.getSeriesTotal())) return true;
|
||||
} else if (db.getSeriesName() != null || db.getSeriesNumber() != null || db.getSeriesTotal() != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean nullSafeEquals(Object a, Object b) {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
return a.equals(b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.dto.settings.SidecarSettings;
|
||||
import org.booklore.model.dto.sidecar.SidecarMetadata;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.util.FileService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SidecarMetadataWriter {
|
||||
|
||||
private final SidecarMetadataMapper mapper;
|
||||
private final FileService fileService;
|
||||
private final AppSettingService appSettingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SidecarMetadataWriter(SidecarMetadataMapper mapper, FileService fileService, AppSettingService appSettingService) {
|
||||
this.mapper = mapper;
|
||||
this.fileService = fileService;
|
||||
this.appSettingService = appSettingService;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
}
|
||||
|
||||
public void writeSidecarMetadata(BookEntity book) {
|
||||
if (book == null || book.getMetadata() == null) {
|
||||
log.warn("Cannot write sidecar metadata: book or metadata is null");
|
||||
return;
|
||||
}
|
||||
|
||||
SidecarSettings settings = getSidecarSettings();
|
||||
if (settings == null || !settings.isEnabled()) {
|
||||
log.debug("Sidecar metadata is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null || !Files.exists(bookPath)) {
|
||||
log.warn("Cannot write sidecar metadata: book file does not exist");
|
||||
return;
|
||||
}
|
||||
|
||||
Path sidecarPath = getSidecarPath(bookPath);
|
||||
BookMetadataEntity metadata = book.getMetadata();
|
||||
|
||||
String coverFileName = null;
|
||||
if (settings.isIncludeCoverFile()) {
|
||||
coverFileName = mapper.getCoverFileName(bookPath);
|
||||
writeCoverFile(book, bookPath.getParent().resolve(coverFileName));
|
||||
}
|
||||
|
||||
SidecarMetadata sidecarMetadata = mapper.toSidecarMetadata(metadata, coverFileName);
|
||||
String json = objectMapper.writeValueAsString(sidecarMetadata);
|
||||
json = json.replace(" : ", ": ").replace("[ ]", "[]");
|
||||
Files.writeString(sidecarPath, json);
|
||||
|
||||
log.info("Wrote sidecar metadata to: {}", sidecarPath);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to write sidecar metadata for book ID {}: {}", book.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteSidecarFiles(Path bookPath) {
|
||||
if (bookPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path sidecarPath = getSidecarPath(bookPath);
|
||||
if (Files.exists(sidecarPath)) {
|
||||
Files.delete(sidecarPath);
|
||||
log.info("Deleted sidecar file: {}", sidecarPath);
|
||||
}
|
||||
|
||||
Path coverPath = getCoverPath(bookPath);
|
||||
if (Files.exists(coverPath)) {
|
||||
Files.delete(coverPath);
|
||||
log.info("Deleted sidecar cover file: {}", coverPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete sidecar files for {}: {}", bookPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void moveSidecarFiles(Path oldBookPath, Path newBookPath) {
|
||||
if (oldBookPath == null || newBookPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path oldSidecarPath = getSidecarPath(oldBookPath);
|
||||
if (Files.exists(oldSidecarPath)) {
|
||||
Path newSidecarPath = getSidecarPath(newBookPath);
|
||||
Files.createDirectories(newSidecarPath.getParent());
|
||||
Files.move(oldSidecarPath, newSidecarPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("Moved sidecar file from {} to {}", oldSidecarPath, newSidecarPath);
|
||||
}
|
||||
|
||||
Path oldCoverPath = getCoverPath(oldBookPath);
|
||||
if (Files.exists(oldCoverPath)) {
|
||||
Path newCoverPath = getCoverPath(newBookPath);
|
||||
Files.move(oldCoverPath, newCoverPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("Moved sidecar cover from {} to {}", oldCoverPath, newCoverPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to move sidecar files from {} to {}: {}", oldBookPath, newBookPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public Path getSidecarPath(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return bookPath.getParent().resolve(baseName + ".metadata.json");
|
||||
}
|
||||
|
||||
public Path getCoverPath(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return bookPath.getParent().resolve(baseName + ".cover.jpg");
|
||||
}
|
||||
|
||||
private void writeCoverFile(BookEntity book, Path coverPath) {
|
||||
try {
|
||||
String coverFile = fileService.getCoverFile(book.getId());
|
||||
Path sourceCoverPath = Path.of(coverFile);
|
||||
if (Files.exists(sourceCoverPath)) {
|
||||
Files.copy(sourceCoverPath, coverPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("Wrote cover file to: {}", coverPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to write cover file for book ID {}: {}", book.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private SidecarSettings getSidecarSettings() {
|
||||
MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings();
|
||||
return settings != null ? settings.getSidecarSettings() : null;
|
||||
}
|
||||
|
||||
public boolean isWriteOnUpdateEnabled() {
|
||||
SidecarSettings settings = getSidecarSettings();
|
||||
return settings != null && settings.isEnabled() && settings.isWriteOnUpdate();
|
||||
}
|
||||
|
||||
public boolean isWriteOnScanEnabled() {
|
||||
SidecarSettings settings = getSidecarSettings();
|
||||
return settings != null && settings.isEnabled() && settings.isWriteOnScan();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.exception.ApiError;
|
||||
import org.booklore.model.MetadataUpdateContext;
|
||||
import org.booklore.model.MetadataUpdateWrapper;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.sidecar.SidecarMetadata;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.LibraryEntity;
|
||||
import org.booklore.model.enums.MetadataReplaceMode;
|
||||
import org.booklore.model.enums.SidecarSyncStatus;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.LibraryRepository;
|
||||
import org.booklore.service.metadata.BookMetadataUpdater;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class SidecarService {
|
||||
|
||||
private final BookRepository bookRepository;
|
||||
private final LibraryRepository libraryRepository;
|
||||
private final SidecarMetadataReader sidecarReader;
|
||||
private final SidecarMetadataWriter sidecarWriter;
|
||||
private final SidecarMetadataMapper sidecarMapper;
|
||||
private final BookMetadataUpdater bookMetadataUpdater;
|
||||
|
||||
public Optional<SidecarMetadata> getSidecarContent(Long bookId) {
|
||||
BookEntity book = bookRepository.findByIdWithBookFiles(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return sidecarReader.readSidecarMetadata(bookPath);
|
||||
}
|
||||
|
||||
public SidecarSyncStatus getSyncStatus(Long bookId) {
|
||||
BookEntity book = bookRepository.findByIdWithBookFiles(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
return sidecarReader.getSyncStatus(book);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void exportToSidecar(Long bookId) {
|
||||
BookEntity book = bookRepository.findByIdWithBookFiles(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
sidecarWriter.writeSidecarMetadata(book);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void importFromSidecar(Long bookId) {
|
||||
BookEntity book = bookRepository.findByIdWithBookFiles(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null) {
|
||||
throw ApiError.FILE_NOT_FOUND.createException("Book has no file path");
|
||||
}
|
||||
|
||||
Optional<SidecarMetadata> sidecarOpt = sidecarReader.readSidecarMetadata(bookPath);
|
||||
if (sidecarOpt.isEmpty()) {
|
||||
throw ApiError.FILE_NOT_FOUND.createException("No sidecar file found for book");
|
||||
}
|
||||
|
||||
SidecarMetadata sidecar = sidecarOpt.get();
|
||||
BookMetadata bookMetadata = sidecarMapper.toBookMetadata(sidecar);
|
||||
|
||||
if (bookMetadata != null) {
|
||||
MetadataUpdateWrapper wrapper = MetadataUpdateWrapper.builder()
|
||||
.metadata(bookMetadata)
|
||||
.build();
|
||||
|
||||
MetadataUpdateContext context = MetadataUpdateContext.builder()
|
||||
.bookEntity(book)
|
||||
.metadataUpdateWrapper(wrapper)
|
||||
.updateThumbnail(false)
|
||||
.replaceMode(MetadataReplaceMode.REPLACE_WHEN_PROVIDED)
|
||||
.build();
|
||||
|
||||
bookMetadataUpdater.setBookMetadata(context);
|
||||
}
|
||||
|
||||
byte[] coverBytes = sidecarReader.readSidecarCover(bookPath);
|
||||
if (coverBytes != null) {
|
||||
log.info("Sidecar cover found for book ID {} - cover import is a separate operation", bookId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int bulkExport(Long libraryId) {
|
||||
LibraryEntity library = libraryRepository.findById(libraryId)
|
||||
.orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
|
||||
|
||||
List<BookEntity> books = bookRepository.findAllByLibraryIdWithFiles(libraryId);
|
||||
int exported = 0;
|
||||
|
||||
for (BookEntity book : books) {
|
||||
try {
|
||||
sidecarWriter.writeSidecarMetadata(book);
|
||||
exported++;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to export sidecar for book ID {}: {}", book.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Bulk exported {} sidecar files for library {}", exported, library.getName());
|
||||
return exported;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int bulkImport(Long libraryId) {
|
||||
LibraryEntity library = libraryRepository.findById(libraryId)
|
||||
.orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
|
||||
|
||||
List<BookEntity> books = bookRepository.findAllByLibraryIdWithFiles(libraryId);
|
||||
int imported = 0;
|
||||
|
||||
for (BookEntity book : books) {
|
||||
try {
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Optional<SidecarMetadata> sidecarOpt = sidecarReader.readSidecarMetadata(bookPath);
|
||||
if (sidecarOpt.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SidecarMetadata sidecar = sidecarOpt.get();
|
||||
BookMetadata bookMetadata = sidecarMapper.toBookMetadata(sidecar);
|
||||
|
||||
if (bookMetadata != null) {
|
||||
MetadataUpdateWrapper wrapper = MetadataUpdateWrapper.builder()
|
||||
.metadata(bookMetadata)
|
||||
.build();
|
||||
|
||||
MetadataUpdateContext context = MetadataUpdateContext.builder()
|
||||
.bookEntity(book)
|
||||
.metadataUpdateWrapper(wrapper)
|
||||
.updateThumbnail(false)
|
||||
.replaceMode(MetadataReplaceMode.REPLACE_WHEN_PROVIDED)
|
||||
.build();
|
||||
|
||||
bookMetadataUpdater.setBookMetadata(context);
|
||||
imported++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to import sidecar for book ID {}: {}", book.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Bulk imported {} sidecar files for library {}", imported, library.getName());
|
||||
return imported;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
-- Create comic_metadata table for storing comic-specific metadata
|
||||
CREATE TABLE IF NOT EXISTS comic_metadata
|
||||
(
|
||||
book_id BIGINT PRIMARY KEY,
|
||||
@@ -27,39 +26,33 @@ CREATE TABLE IF NOT EXISTS comic_metadata
|
||||
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,
|
||||
@@ -99,3 +92,39 @@ CREATE TABLE IF NOT EXISTS comic_metadata_creator_mapping
|
||||
|
||||
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);
|
||||
|
||||
CREATE TRIGGER trg_cleanup_orphaned_comic_character
|
||||
AFTER DELETE
|
||||
ON comic_metadata_character_mapping
|
||||
FOR EACH ROW
|
||||
DELETE
|
||||
FROM comic_character
|
||||
WHERE id = OLD.character_id
|
||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_character_mapping WHERE character_id = OLD.character_id);
|
||||
|
||||
CREATE TRIGGER trg_cleanup_orphaned_comic_team
|
||||
AFTER DELETE
|
||||
ON comic_metadata_team_mapping
|
||||
FOR EACH ROW
|
||||
DELETE
|
||||
FROM comic_team
|
||||
WHERE id = OLD.team_id
|
||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_team_mapping WHERE team_id = OLD.team_id);
|
||||
|
||||
CREATE TRIGGER trg_cleanup_orphaned_comic_location
|
||||
AFTER DELETE
|
||||
ON comic_metadata_location_mapping
|
||||
FOR EACH ROW
|
||||
DELETE
|
||||
FROM comic_location
|
||||
WHERE id = OLD.location_id
|
||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_location_mapping WHERE location_id = OLD.location_id);
|
||||
|
||||
CREATE TRIGGER trg_cleanup_orphaned_comic_creator
|
||||
AFTER DELETE
|
||||
ON comic_metadata_creator_mapping
|
||||
FOR EACH ROW
|
||||
DELETE
|
||||
FROM comic_creator
|
||||
WHERE id = OLD.creator_id
|
||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_creator_mapping WHERE creator_id = OLD.creator_id);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE library
|
||||
ADD COLUMN metadata_source VARCHAR(20) DEFAULT 'EMBEDDED';
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE library_path
|
||||
DROP FOREIGN KEY fk_library_path;
|
||||
ALTER TABLE library_path
|
||||
ADD CONSTRAINT fk_library_path FOREIGN KEY (library_id) REFERENCES library (id) ON DELETE CASCADE;
|
||||
@@ -8,6 +8,7 @@ import org.booklore.service.book.BookQueryService;
|
||||
import org.booklore.service.book.BookService;
|
||||
import org.booklore.service.book.BookUpdateService;
|
||||
import org.booklore.service.progress.ReadingProgressService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.booklore.util.FileService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -51,6 +52,7 @@ class BookServiceDeleteTests {
|
||||
BookDownloadService bookDownloadService = Mockito.mock(BookDownloadService.class);
|
||||
MonitoringRegistrationService monitoringRegistrationService = Mockito.mock(MonitoringRegistrationService.class);
|
||||
BookUpdateService bookUpdateService = Mockito.mock(BookUpdateService.class);
|
||||
SidecarMetadataWriter sidecarMetadataWriter = Mockito.mock(SidecarMetadataWriter.class);
|
||||
|
||||
bookService = new BookService(
|
||||
bookRepository,
|
||||
@@ -67,7 +69,8 @@ class BookServiceDeleteTests {
|
||||
bookDownloadService,
|
||||
monitoringRegistrationService,
|
||||
bookUpdateService,
|
||||
ebookViewerPreferenceRepository
|
||||
ebookViewerPreferenceRepository,
|
||||
sidecarMetadataWriter
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.booklore.repository.BookAdditionalFileRepository;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.LibraryRepository;
|
||||
import org.booklore.service.NotificationService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -57,6 +58,7 @@ class FileMoveServiceOrderingTest {
|
||||
|
||||
@Mock private EntityManager entityManager;
|
||||
@Mock private TransactionTemplate transactionTemplate;
|
||||
@Mock private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
private FileMoveService service;
|
||||
private LibraryEntity library;
|
||||
@@ -67,9 +69,9 @@ class FileMoveServiceOrderingTest {
|
||||
LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper,
|
||||
MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper,
|
||||
BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
TransactionTemplate transactionTemplate, SidecarMetadataWriter sidecarMetadataWriter) {
|
||||
super(appProperties, bookRepository, bookFileRepository, libraryRepository, fileMoveHelper,
|
||||
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate);
|
||||
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate, sidecarMetadataWriter);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -89,7 +91,7 @@ class FileMoveServiceOrderingTest {
|
||||
}).when(transactionTemplate).executeWithoutResult(any());
|
||||
|
||||
service = spy(new TestableFileMoveService(appProperties, bookRepository, bookFileRepository, libraryRepository,
|
||||
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate));
|
||||
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate, sidecarMetadataWriter));
|
||||
|
||||
library = new LibraryEntity();
|
||||
library.setId(1L);
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.booklore.repository.BookAdditionalFileRepository;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.LibraryRepository;
|
||||
import org.booklore.service.NotificationService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -52,6 +53,7 @@ class FileMoveServiceTest {
|
||||
@Mock private NotificationService notificationService;
|
||||
@Mock private EntityManager entityManager;
|
||||
@Mock private org.springframework.transaction.support.TransactionTemplate transactionTemplate;
|
||||
@Mock private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
private FileMoveService service;
|
||||
private LibraryEntity library;
|
||||
@@ -62,9 +64,10 @@ class FileMoveServiceTest {
|
||||
LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper,
|
||||
MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper,
|
||||
BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager,
|
||||
org.springframework.transaction.support.TransactionTemplate transactionTemplate) {
|
||||
org.springframework.transaction.support.TransactionTemplate transactionTemplate,
|
||||
SidecarMetadataWriter sidecarMetadataWriter) {
|
||||
super(appProperties, bookRepository, bookFileRepository, libraryRepository, fileMoveHelper,
|
||||
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate);
|
||||
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate, sidecarMetadataWriter);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -85,7 +88,7 @@ class FileMoveServiceTest {
|
||||
}).when(transactionTemplate).executeWithoutResult(any());
|
||||
|
||||
service = spy(new TestableFileMoveService(appProperties, bookRepository, bookFileRepository, libraryRepository,
|
||||
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate));
|
||||
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate, sidecarMetadataWriter));
|
||||
|
||||
library = new LibraryEntity();
|
||||
library.setId(1L);
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.CbxMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.FileService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -41,6 +42,7 @@ class CbxProcessorTest {
|
||||
@Mock private BookMapper bookMapper;
|
||||
@Mock private FileService fileService;
|
||||
@Mock private MetadataMatchService metadataMatchService;
|
||||
@Mock private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
@Mock private CbxMetadataExtractor cbxMetadataExtractor;
|
||||
|
||||
private CbxProcessor cbxProcessor;
|
||||
@@ -57,6 +59,7 @@ class CbxProcessorTest {
|
||||
bookMapper,
|
||||
fileService,
|
||||
metadataMatchService,
|
||||
sidecarMetadataWriter,
|
||||
cbxMetadataExtractor
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.MetadataReplaceMode;
|
||||
import org.booklore.repository.*;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.file.FileMoveService;
|
||||
import org.booklore.service.metadata.writer.MetadataWriterFactory;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -47,6 +48,16 @@ class BookMetadataUpdaterCategoryTest {
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
@Mock
|
||||
private ComicMetadataRepository comicMetadataRepository;
|
||||
@Mock
|
||||
private ComicCharacterRepository comicCharacterRepository;
|
||||
@Mock
|
||||
private ComicTeamRepository comicTeamRepository;
|
||||
@Mock
|
||||
private ComicLocationRepository comicLocationRepository;
|
||||
@Mock
|
||||
private ComicCreatorRepository comicCreatorRepository;
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
@Mock
|
||||
private MetadataMatchService metadataMatchService;
|
||||
@@ -58,6 +69,8 @@ class BookMetadataUpdaterCategoryTest {
|
||||
private BookReviewUpdateService bookReviewUpdateService;
|
||||
@Mock
|
||||
private FileMoveService fileMoveService;
|
||||
@Mock
|
||||
private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
@InjectMocks
|
||||
private BookMetadataUpdater bookMetadataUpdater;
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.MetadataReplaceMode;
|
||||
import org.booklore.repository.*;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.file.FileMoveService;
|
||||
import org.booklore.service.metadata.writer.MetadataWriterFactory;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -40,6 +41,16 @@ class BookMetadataUpdaterTest {
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
@Mock
|
||||
private ComicMetadataRepository comicMetadataRepository;
|
||||
@Mock
|
||||
private ComicCharacterRepository comicCharacterRepository;
|
||||
@Mock
|
||||
private ComicTeamRepository comicTeamRepository;
|
||||
@Mock
|
||||
private ComicLocationRepository comicLocationRepository;
|
||||
@Mock
|
||||
private ComicCreatorRepository comicCreatorRepository;
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
@Mock
|
||||
private MetadataMatchService metadataMatchService;
|
||||
@@ -51,6 +62,8 @@ class BookMetadataUpdaterTest {
|
||||
private BookReviewUpdateService bookReviewUpdateService;
|
||||
@Mock
|
||||
private FileMoveService fileMoveService;
|
||||
@Mock
|
||||
private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
@InjectMocks
|
||||
private BookMetadataUpdater bookMetadataUpdater;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {SortOption} from './sort.model';
|
||||
import {BookType} from './book.model';
|
||||
|
||||
export type MetadataSource = 'EMBEDDED' | 'SIDECAR' | 'PREFER_SIDECAR' | 'PREFER_EMBEDDED' | 'NONE';
|
||||
|
||||
export interface Library {
|
||||
id?: number;
|
||||
name: string;
|
||||
@@ -12,6 +14,7 @@ export interface Library {
|
||||
paths: LibraryPath[];
|
||||
formatPriority?: BookType[];
|
||||
allowedFormats?: BookType[];
|
||||
metadataSource?: MetadataSource;
|
||||
}
|
||||
|
||||
export interface LibraryPath {
|
||||
|
||||
@@ -111,32 +111,54 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section collapsible">
|
||||
<section class="form-section">
|
||||
<div class="section-header">
|
||||
<i class="pi pi-cog"></i>
|
||||
<span>Options</span>
|
||||
</div>
|
||||
|
||||
<div class="options-grid">
|
||||
<div class="option-item">
|
||||
<div class="options-list">
|
||||
<div class="option-row">
|
||||
<div class="option-label">
|
||||
<span>Watch folders</span>
|
||||
<i
|
||||
class="pi pi-question-circle info-icon"
|
||||
pTooltip="Automatically detect new books added to folders"
|
||||
tooltipPosition="right"
|
||||
tooltipPosition="top"
|
||||
></i>
|
||||
</div>
|
||||
<p-toggleswitch [(ngModel)]="watch" />
|
||||
</div>
|
||||
|
||||
<div class="option-item format-priority-option">
|
||||
<div class="option-row">
|
||||
<div class="option-label">
|
||||
<span>Metadata source</span>
|
||||
<i
|
||||
class="pi pi-question-circle info-icon"
|
||||
pTooltip="Choose where to read metadata from when scanning books"
|
||||
tooltipPosition="top"
|
||||
></i>
|
||||
</div>
|
||||
<p-select
|
||||
[(ngModel)]="metadataSource"
|
||||
[options]="metadataSourceOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select source"
|
||||
class="metadata-source-select"
|
||||
appendTo="body"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="option-divider"></div>
|
||||
|
||||
<div class="option-row option-row-stacked">
|
||||
<div class="option-label">
|
||||
<span>Format priority</span>
|
||||
<i
|
||||
class="pi pi-question-circle info-icon"
|
||||
pTooltip="Drag to set preferred format when a book has multiple files"
|
||||
tooltipPosition="right"
|
||||
pTooltip="Drag to reorder preferred format when a book has multiple files"
|
||||
tooltipPosition="top"
|
||||
></i>
|
||||
</div>
|
||||
<div
|
||||
@@ -156,59 +178,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option-item allowed-formats-option">
|
||||
<div class="option-row">
|
||||
<div class="option-label">
|
||||
<span>Allowed formats</span>
|
||||
<i
|
||||
class="pi pi-question-circle info-icon"
|
||||
pTooltip="Select which book formats this library should accept. Files of other formats will be ignored during scans."
|
||||
tooltipPosition="right"
|
||||
pTooltip="Select which book formats to include in scans"
|
||||
tooltipPosition="top"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<div class="allowed-formats-container">
|
||||
<div class="allow-all-toggle">
|
||||
<p-checkbox
|
||||
[(ngModel)]="allowAllFormats"
|
||||
[binary]="true"
|
||||
inputId="allowAll"
|
||||
(onChange)="onAllowAllFormatsChange()"
|
||||
/>
|
||||
<label for="allowAll" class="allow-all-label">Allow all formats</label>
|
||||
</div>
|
||||
|
||||
@if (!allowAllFormats) {
|
||||
<div class="format-checkboxes">
|
||||
@for (format of allBookFormats; track format.type) {
|
||||
<div class="format-checkbox-item" [class.has-warning]="getFormatWarning(format.type)">
|
||||
<p-checkbox
|
||||
[ngModel]="isFormatSelected(format.type)"
|
||||
[binary]="true"
|
||||
[inputId]="'format-' + format.type"
|
||||
(onChange)="onFormatCheckboxChange(format.type, $event.checked)"
|
||||
/>
|
||||
<label [for]="'format-' + format.type" class="format-checkbox-label">
|
||||
{{ format.label }}
|
||||
</label>
|
||||
@if (getFormatWarning(format.type); as warning) {
|
||||
<i
|
||||
class="pi pi-exclamation-triangle warning-icon"
|
||||
[pTooltip]="warning"
|
||||
tooltipPosition="top"
|
||||
></i>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (hasAnyFormatWarning()) {
|
||||
<div class="format-warning-message">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>Deselected formats with existing books will not be affected, but won't appear in future scans</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<div class="allow-all-toggle">
|
||||
<p-checkbox
|
||||
[(ngModel)]="allowAllFormats"
|
||||
[binary]="true"
|
||||
inputId="allowAll"
|
||||
(onChange)="onAllowAllFormatsChange()"
|
||||
/>
|
||||
<label for="allowAll" class="allow-all-label">Allow all</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!allowAllFormats) {
|
||||
<div class="format-checkboxes">
|
||||
@for (format of allBookFormats; track format.type) {
|
||||
<div class="format-checkbox-item" [class.has-warning]="getFormatWarning(format.type)">
|
||||
<p-checkbox
|
||||
[ngModel]="isFormatSelected(format.type)"
|
||||
[binary]="true"
|
||||
[inputId]="'format-' + format.type"
|
||||
(onChange)="onFormatCheckboxChange(format.type, $event.checked)"
|
||||
/>
|
||||
<label [for]="'format-' + format.type" class="format-checkbox-label">
|
||||
{{ format.label }}
|
||||
</label>
|
||||
@if (getFormatWarning(format.type); as warning) {
|
||||
<i
|
||||
class="pi pi-exclamation-triangle warning-icon"
|
||||
[pTooltip]="warning"
|
||||
tooltipPosition="top"
|
||||
></i>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (hasAnyFormatWarning()) {
|
||||
<div class="format-warning-message">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
<span>Deselected formats won't appear in future scans</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -388,61 +388,63 @@
|
||||
}
|
||||
|
||||
// Options section
|
||||
.options-grid {
|
||||
.options-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--ground-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
|
||||
p-toggleswitch {
|
||||
--p-toggleswitch-checked-background: var(--p-green-500);
|
||||
--p-toggleswitch-checked-hover-background: var(--p-green-600);
|
||||
}
|
||||
|
||||
&.format-priority-option,
|
||||
&.allowed-formats-option {
|
||||
&.option-row-stacked {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
.option-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 0.9rem;
|
||||
color: var(--p-blue-400);
|
||||
cursor: help;
|
||||
transition: color 0.2s ease;
|
||||
.option-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.info-icon {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary-color);
|
||||
opacity: 0.6;
|
||||
cursor: help;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed formats
|
||||
.allowed-formats-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.allow-all-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -458,8 +460,8 @@
|
||||
.format-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
padding: 0.75rem;
|
||||
gap: 0.625rem 1.25rem;
|
||||
padding: 0.875rem;
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
@@ -469,10 +471,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 100px;
|
||||
min-width: 90px;
|
||||
|
||||
.format-checkbox-label {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -480,7 +482,7 @@
|
||||
.warning-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--p-orange-400);
|
||||
margin-left: 0.25rem;
|
||||
margin-left: 0.125rem;
|
||||
}
|
||||
|
||||
&.has-warning {
|
||||
@@ -490,20 +492,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .metadata-source-select {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.format-warning-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border: 1px solid var(--p-orange-400);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--p-orange-400);
|
||||
|
||||
i {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +517,7 @@
|
||||
.format-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.625rem;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -519,7 +525,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.4rem 0.625rem;
|
||||
background: var(--card-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
@@ -544,17 +550,15 @@
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.drag-indicator {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary-color);
|
||||
opacity: 0.7;
|
||||
padding: 0.25rem;
|
||||
margin: -0.25rem;
|
||||
opacity: 0.5;
|
||||
cursor: grab;
|
||||
|
||||
&:hover {
|
||||
@@ -668,15 +672,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
.format-chips {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.format-chip {
|
||||
padding: 0.375rem 0.5rem;
|
||||
|
||||
.chip-label {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {Router} from '@angular/router';
|
||||
import {LibraryService} from '../book/service/library.service';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
import {Library} from '../book/model/library.model';
|
||||
import {Library, MetadataSource} from '../book/model/library.model';
|
||||
import {BookType} from '../book/model/book.model';
|
||||
import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
@@ -17,12 +17,13 @@ import {switchMap} from 'rxjs/operators';
|
||||
import {map} from 'rxjs';
|
||||
import {CdkDragDrop, DragDropModule, moveItemInArray} from '@angular/cdk/drag-drop';
|
||||
import {Checkbox} from 'primeng/checkbox';
|
||||
import {Select} from 'primeng/select';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-creator',
|
||||
standalone: true,
|
||||
templateUrl: './library-creator.component.html',
|
||||
imports: [FormsModule, InputText, ToggleSwitch, Tooltip, Button, IconDisplayComponent, DragDropModule, Checkbox],
|
||||
imports: [FormsModule, InputText, ToggleSwitch, Tooltip, Button, IconDisplayComponent, DragDropModule, Checkbox, Select],
|
||||
styleUrl: './library-creator.component.scss'
|
||||
})
|
||||
export class LibraryCreatorComponent implements OnInit {
|
||||
@@ -38,6 +39,15 @@ export class LibraryCreatorComponent implements OnInit {
|
||||
allowAllFormats: boolean = true;
|
||||
selectedAllowedFormats: Set<BookType> = new Set();
|
||||
formatCounts: Record<string, number> = {};
|
||||
metadataSource: MetadataSource = 'EMBEDDED';
|
||||
|
||||
readonly metadataSourceOptions = [
|
||||
{label: 'Embedded Only', value: 'EMBEDDED', description: 'Use only embedded file metadata'},
|
||||
{label: 'Sidecar Only', value: 'SIDECAR', description: 'Use only sidecar JSON files'},
|
||||
{label: 'Prefer Sidecar', value: 'PREFER_SIDECAR', description: 'Use sidecar if available, fallback to embedded'},
|
||||
{label: 'Prefer Embedded', value: 'PREFER_EMBEDDED', description: 'Use embedded if available, fallback to sidecar'},
|
||||
{label: 'None', value: 'NONE', description: 'Don\'t read metadata from files'}
|
||||
];
|
||||
|
||||
readonly allBookFormats: {type: BookType, label: string}[] = [
|
||||
{type: 'EPUB', label: 'EPUB'},
|
||||
@@ -100,6 +110,10 @@ export class LibraryCreatorComponent implements OnInit {
|
||||
this.selectedAllowedFormats = new Set(this.allBookFormats.map(f => f.type));
|
||||
}
|
||||
|
||||
if (this.library.metadataSource) {
|
||||
this.metadataSource = this.library.metadataSource;
|
||||
}
|
||||
|
||||
this.libraryService.getBookCountsByFormat(this.library.id!).subscribe(counts => {
|
||||
this.formatCounts = counts;
|
||||
});
|
||||
@@ -219,7 +233,8 @@ export class LibraryCreatorComponent implements OnInit {
|
||||
paths: this.folders.map(folder => ({path: folder})),
|
||||
watch: this.watch,
|
||||
formatPriority: this.formatPriority.map(f => f.type),
|
||||
allowedFormats: this.allowAllFormats ? [] : Array.from(this.selectedAllowedFormats)
|
||||
allowedFormats: this.allowAllFormats ? [] : Array.from(this.selectedAllowedFormats),
|
||||
metadataSource: this.metadataSource
|
||||
};
|
||||
|
||||
if (this.mode === 'edit') {
|
||||
|
||||
@@ -37,6 +37,12 @@
|
||||
Search Metadata
|
||||
</p-tab>
|
||||
}
|
||||
@if (admin || canEditMetadata) {
|
||||
<p-tab value="sidecar">
|
||||
<i [class]="'pi pi-file'"></i>
|
||||
Sidecar
|
||||
</p-tab>
|
||||
}
|
||||
</p-tablist>
|
||||
<p-tabpanels class="tabpanels-responsive">
|
||||
<p-tabpanel value="view">
|
||||
@@ -57,6 +63,11 @@
|
||||
<app-metadata-searcher [book$]="book$"></app-metadata-searcher>
|
||||
</p-tabpanel>
|
||||
}
|
||||
@if (admin || canEditMetadata) {
|
||||
<p-tabpanel value="sidecar">
|
||||
<app-sidecar-viewer [book$]="book$"></app-sidecar-viewer>
|
||||
</p-tabpanel>
|
||||
}
|
||||
</p-tabpanels>
|
||||
</p-tabs>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {BookMetadataHostService} from '../../../../shared/service/book-metadata-
|
||||
import {MetadataViewerComponent} from './metadata-viewer/metadata-viewer.component';
|
||||
import {MetadataEditorComponent} from './metadata-editor/metadata-editor.component';
|
||||
import {MetadataSearcherComponent} from './metadata-searcher/metadata-searcher.component';
|
||||
import {SidecarViewerComponent} from './sidecar-viewer/sidecar-viewer.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-book-metadata-center',
|
||||
@@ -27,6 +28,7 @@ import {MetadataSearcherComponent} from './metadata-searcher/metadata-searcher.c
|
||||
MetadataViewerComponent,
|
||||
MetadataEditorComponent,
|
||||
MetadataSearcherComponent,
|
||||
SidecarViewerComponent,
|
||||
Button
|
||||
],
|
||||
styleUrls: ['./book-metadata-center.component.scss'],
|
||||
@@ -48,7 +50,7 @@ export class BookMetadataCenterComponent implements OnInit, OnDestroy {
|
||||
|
||||
private appSettings$ = this.appSettingsService.appSettings$;
|
||||
private currentBookId$ = new BehaviorSubject<number | null>(null);
|
||||
private validTabs = ['view', 'edit', 'match'];
|
||||
private validTabs = ['view', 'edit', 'match', 'sidecar'];
|
||||
|
||||
get tab(): string {
|
||||
return this._tab;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<div class="sidecar-viewer">
|
||||
<div class="sidecar-header">
|
||||
<div class="header-info">
|
||||
<h3>Sidecar Metadata</h3>
|
||||
<p class="header-description">
|
||||
View and manage the .metadata.json file stored alongside your book file.
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<p-tag
|
||||
[value]="getSyncStatusLabel()"
|
||||
[severity]="getSyncStatusSeverity()"
|
||||
styleClass="sync-status-tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading) {
|
||||
<div class="loading-state">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<span>Loading sidecar data...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="sidecar-actions">
|
||||
<p-button
|
||||
label="Export to Sidecar"
|
||||
icon="pi pi-upload"
|
||||
[loading]="exporting"
|
||||
(onClick)="exportToSidecar()"
|
||||
severity="primary"
|
||||
size="small"
|
||||
pTooltip="Create or update the sidecar JSON file with current database metadata"
|
||||
tooltipPosition="top"
|
||||
/>
|
||||
<p-button
|
||||
label="Import from Sidecar"
|
||||
icon="pi pi-download"
|
||||
[loading]="importing"
|
||||
[disabled]="syncStatus === 'MISSING'"
|
||||
(onClick)="importFromSidecar()"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
pTooltip="Import metadata from the sidecar JSON file into the database"
|
||||
tooltipPosition="top"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (syncStatus === 'MISSING') {
|
||||
<div class="empty-state">
|
||||
<i class="pi pi-file"></i>
|
||||
<h4>No Sidecar File Found</h4>
|
||||
<p>
|
||||
No .metadata.json file exists for this book yet.
|
||||
Click "Export to Sidecar" to create one with the current metadata.
|
||||
</p>
|
||||
</div>
|
||||
} @else if (sidecarContent) {
|
||||
<div class="sidecar-content">
|
||||
<div class="content-header">
|
||||
<div class="content-meta">
|
||||
<span class="meta-item">
|
||||
<i class="pi pi-clock"></i>
|
||||
Generated: {{ sidecarContent.generatedAt | date:'medium' }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="pi pi-tag"></i>
|
||||
Version: {{ sidecarContent.version }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<i class="pi pi-cog"></i>
|
||||
By: {{ sidecarContent.generatedBy }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="json-viewer">
|
||||
<pre class="json-content">{{ sidecarContent | json }}</pre>
|
||||
</div>
|
||||
|
||||
@if (sidecarContent.cover) {
|
||||
<div class="cover-info">
|
||||
<i class="pi pi-image"></i>
|
||||
<span>Cover file: {{ sidecarContent.cover.path }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,211 @@
|
||||
.sidecar-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sidecar-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
@media (min-width: 640px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.header-description {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
::ng-deep .sync-status-tag {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
gap: 1rem;
|
||||
color: var(--text-color-secondary);
|
||||
|
||||
i {
|
||||
font-size: 2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sidecar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
background: var(--surface-ground);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
|
||||
i {
|
||||
font-size: 3rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.sidecar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.content-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-secondary);
|
||||
|
||||
i {
|
||||
font-size: 0.875rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.json-viewer {
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.json-content {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cover-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
|
||||
i {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import {Component, inject, Input, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Observable, Subject} from 'rxjs';
|
||||
import {filter, switchMap, takeUntil} from 'rxjs/operators';
|
||||
import {Book} from '../../../../book/model/book.model';
|
||||
import {SidecarMetadata, SidecarService, SidecarSyncStatus} from '../../../service/sidecar.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Button} from 'primeng/button';
|
||||
import {Tag} from 'primeng/tag';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {DatePipe, JsonPipe} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidecar-viewer',
|
||||
standalone: true,
|
||||
templateUrl: './sidecar-viewer.component.html',
|
||||
styleUrls: ['./sidecar-viewer.component.scss'],
|
||||
imports: [Button, Tag, Tooltip, JsonPipe, DatePipe]
|
||||
})
|
||||
export class SidecarViewerComponent implements OnInit, OnDestroy {
|
||||
@Input() book$!: Observable<Book>;
|
||||
|
||||
private sidecarService = inject(SidecarService);
|
||||
private messageService = inject(MessageService);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
sidecarContent: SidecarMetadata | null = null;
|
||||
syncStatus: SidecarSyncStatus = 'NOT_APPLICABLE';
|
||||
loading = false;
|
||||
exporting = false;
|
||||
importing = false;
|
||||
currentBookId: number | null = null;
|
||||
error: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.book$.pipe(
|
||||
filter((book): book is Book => !!book),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(book => {
|
||||
this.currentBookId = book.id;
|
||||
this.loadSidecarData(book.id);
|
||||
});
|
||||
}
|
||||
|
||||
loadSidecarData(bookId: number): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
this.sidecarService.getSyncStatus(bookId).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe({
|
||||
next: (response) => {
|
||||
this.syncStatus = response.status;
|
||||
|
||||
if (response.status !== 'MISSING' && response.status !== 'NOT_APPLICABLE') {
|
||||
this.loadSidecarContent(bookId);
|
||||
} else {
|
||||
this.sidecarContent = null;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.syncStatus = 'NOT_APPLICABLE';
|
||||
this.sidecarContent = null;
|
||||
this.loading = false;
|
||||
console.error('Failed to get sync status:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadSidecarContent(bookId: number): void {
|
||||
this.sidecarService.getSidecarContent(bookId).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe({
|
||||
next: (content) => {
|
||||
this.sidecarContent = content;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.sidecarContent = null;
|
||||
this.loading = false;
|
||||
if (err.status !== 404) {
|
||||
console.error('Failed to load sidecar content:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
exportToSidecar(): void {
|
||||
if (!this.currentBookId) return;
|
||||
|
||||
this.exporting = true;
|
||||
this.sidecarService.exportToSidecar(this.currentBookId).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Export Successful',
|
||||
detail: 'Sidecar metadata file has been created.'
|
||||
});
|
||||
this.loadSidecarData(this.currentBookId!);
|
||||
this.exporting = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Export Failed',
|
||||
detail: 'Failed to create sidecar metadata file.'
|
||||
});
|
||||
this.exporting = false;
|
||||
console.error('Export failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
importFromSidecar(): void {
|
||||
if (!this.currentBookId) return;
|
||||
|
||||
this.importing = true;
|
||||
this.sidecarService.importFromSidecar(this.currentBookId).pipe(
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Import Successful',
|
||||
detail: 'Metadata has been imported from sidecar file.'
|
||||
});
|
||||
this.loadSidecarData(this.currentBookId!);
|
||||
this.importing = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Import Failed',
|
||||
detail: 'Failed to import metadata from sidecar file.'
|
||||
});
|
||||
this.importing = false;
|
||||
console.error('Import failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getSyncStatusSeverity(): 'success' | 'info' | 'warn' | 'danger' | 'secondary' | 'contrast' {
|
||||
switch (this.syncStatus) {
|
||||
case 'IN_SYNC':
|
||||
return 'success';
|
||||
case 'OUTDATED':
|
||||
return 'warn';
|
||||
case 'CONFLICT':
|
||||
return 'danger';
|
||||
case 'MISSING':
|
||||
return 'secondary';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
getSyncStatusLabel(): string {
|
||||
switch (this.syncStatus) {
|
||||
case 'IN_SYNC':
|
||||
return 'In Sync';
|
||||
case 'OUTDATED':
|
||||
return 'Outdated';
|
||||
case 'CONFLICT':
|
||||
return 'Conflict';
|
||||
case 'MISSING':
|
||||
return 'Missing';
|
||||
case 'NOT_APPLICABLE':
|
||||
return 'N/A';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
110
booklore-ui/src/app/features/metadata/service/sidecar.service.ts
Normal file
110
booklore-ui/src/app/features/metadata/service/sidecar.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {API_CONFIG} from '../../../core/config/api-config';
|
||||
|
||||
export interface SidecarCoverInfo {
|
||||
source: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface SidecarSeries {
|
||||
name?: string;
|
||||
number?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface SidecarIdentifiers {
|
||||
asin?: string;
|
||||
goodreadsId?: string;
|
||||
googleId?: string;
|
||||
hardcoverId?: string;
|
||||
comicvineId?: string;
|
||||
lubimyczytacId?: string;
|
||||
ranobedbId?: string;
|
||||
audibleId?: string;
|
||||
}
|
||||
|
||||
export interface SidecarRating {
|
||||
average?: number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface SidecarRatings {
|
||||
amazon?: SidecarRating;
|
||||
goodreads?: SidecarRating;
|
||||
hardcover?: SidecarRating;
|
||||
lubimyczytac?: SidecarRating;
|
||||
ranobedb?: SidecarRating;
|
||||
audible?: SidecarRating;
|
||||
}
|
||||
|
||||
export interface SidecarBookMetadata {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
authors?: string[];
|
||||
publisher?: string;
|
||||
publishedDate?: string;
|
||||
description?: string;
|
||||
isbn10?: string;
|
||||
isbn13?: string;
|
||||
language?: string;
|
||||
pageCount?: number;
|
||||
categories?: string[];
|
||||
moods?: string[];
|
||||
tags?: string[];
|
||||
series?: SidecarSeries;
|
||||
identifiers?: SidecarIdentifiers;
|
||||
ratings?: SidecarRatings;
|
||||
ageRating?: number;
|
||||
contentRating?: string;
|
||||
narrator?: string;
|
||||
abridged?: boolean;
|
||||
}
|
||||
|
||||
export interface SidecarMetadata {
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
generatedBy: string;
|
||||
metadata: SidecarBookMetadata;
|
||||
cover?: SidecarCoverInfo;
|
||||
}
|
||||
|
||||
export type SidecarSyncStatus = 'IN_SYNC' | 'OUTDATED' | 'MISSING' | 'CONFLICT' | 'NOT_APPLICABLE';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SidecarService {
|
||||
private readonly apiUrl = `${API_CONFIG.BASE_URL}/api/v1`;
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getSidecarContent(bookId: number): Observable<SidecarMetadata> {
|
||||
return this.http.get<SidecarMetadata>(`${this.apiUrl}/books/${bookId}/sidecar`);
|
||||
}
|
||||
|
||||
getSyncStatus(bookId: number): Observable<{status: SidecarSyncStatus}> {
|
||||
return this.http.get<{status: SidecarSyncStatus}>(`${this.apiUrl}/books/${bookId}/sidecar/status`);
|
||||
}
|
||||
|
||||
exportToSidecar(bookId: number): Observable<{message: string}> {
|
||||
return this.http.post<{message: string}>(`${this.apiUrl}/books/${bookId}/sidecar/export`, {});
|
||||
}
|
||||
|
||||
importFromSidecar(bookId: number): Observable<{message: string}> {
|
||||
return this.http.post<{message: string}>(`${this.apiUrl}/books/${bookId}/sidecar/import`, {});
|
||||
}
|
||||
|
||||
bulkExport(libraryId: number): Observable<{message: string, exported: number}> {
|
||||
return this.http.post<{message: string, exported: number}>(
|
||||
`${this.apiUrl}/libraries/${libraryId}/sidecar/export-all`, {}
|
||||
);
|
||||
}
|
||||
|
||||
bulkImport(libraryId: number): Observable<{message: string, imported: number}> {
|
||||
return this.http.post<{message: string, imported: number}>(
|
||||
`${this.apiUrl}/libraries/${libraryId}/sidecar/import-all`, {}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,30 @@
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="sidecar-actions">
|
||||
<p-button
|
||||
icon="pi pi-upload"
|
||||
label="Export Sidecar"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
[outlined]="true"
|
||||
[loading]="isSidecarExporting(library.id!)"
|
||||
(onClick)="exportSidecarForLibrary(library.id!, $event)"
|
||||
pTooltip="Generate .metadata.json files for all books in this library"
|
||||
tooltipPosition="top"
|
||||
/>
|
||||
<p-button
|
||||
icon="pi pi-download"
|
||||
label="Import Sidecar"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
[outlined]="true"
|
||||
[loading]="isSidecarImporting(library.id!)"
|
||||
(onClick)="importSidecarForLibrary(library.id!, $event)"
|
||||
pTooltip="Import metadata from existing .metadata.json files in this library"
|
||||
tooltipPosition="top"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
.accordion-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -125,6 +126,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidecar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.library-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -5,6 +5,8 @@ import {Observable} from 'rxjs';
|
||||
import {map} from 'rxjs/operators';
|
||||
import {AccordionModule} from 'primeng/accordion';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Button} from 'primeng/button';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
|
||||
import {Library} from '../../book/model/library.model';
|
||||
import {LibraryService} from '../../book/service/library.service';
|
||||
@@ -13,11 +15,12 @@ import {AppSettingKey, AppSettings} from '../../../shared/model/app-settings.mod
|
||||
import {AppSettingsService} from '../../../shared/service/app-settings.service';
|
||||
import {ExternalDocLinkComponent} from '../../../shared/components/external-doc-link/external-doc-link.component';
|
||||
import {MetadataAdvancedFetchOptionsComponent} from '../../metadata/component/metadata-options-dialog/metadata-advanced-fetch-options/metadata-advanced-fetch-options.component';
|
||||
import {SidecarService} from '../../metadata/service/sidecar.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-metadata-settings-component',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, MetadataAdvancedFetchOptionsComponent, AccordionModule, ExternalDocLinkComponent],
|
||||
imports: [CommonModule, FormsModule, MetadataAdvancedFetchOptionsComponent, AccordionModule, ExternalDocLinkComponent, Button, Tooltip],
|
||||
templateUrl: './library-metadata-settings.component.html',
|
||||
styleUrls: ['./library-metadata-settings.component.scss']
|
||||
})
|
||||
@@ -25,6 +28,7 @@ export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
private libraryService = inject(LibraryService);
|
||||
private appSettingsService = inject(AppSettingsService);
|
||||
private messageService = inject(MessageService);
|
||||
private sidecarService = inject(SidecarService);
|
||||
|
||||
libraries$: Observable<Library[]> = this.libraryService.libraryState$.pipe(
|
||||
map(state => state.libraries || [])
|
||||
@@ -33,6 +37,8 @@ export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
defaultMetadataOptions: MetadataRefreshOptions = this.getDefaultMetadataOptions();
|
||||
libraryMetadataOptions: Record<number, MetadataRefreshOptions> = {};
|
||||
activePanel: number | null = null;
|
||||
sidecarExporting: Record<number, boolean> = {};
|
||||
sidecarImporting: Record<number, boolean> = {};
|
||||
|
||||
ngOnInit() {
|
||||
this.appSettingsService.appSettings$.subscribe(appSettings => {
|
||||
@@ -232,4 +238,46 @@ export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
exportSidecarForLibrary(libraryId: number, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.sidecarExporting[libraryId] = true;
|
||||
|
||||
this.sidecarService.bulkExport(libraryId).subscribe({
|
||||
next: (response) => {
|
||||
this.sidecarExporting[libraryId] = false;
|
||||
this.showMessage('success', 'Export Complete', `Exported sidecar files for ${response.exported} books.`);
|
||||
},
|
||||
error: (error) => {
|
||||
this.sidecarExporting[libraryId] = false;
|
||||
console.error('Bulk sidecar export failed:', error);
|
||||
this.showMessage('error', 'Export Failed', 'Failed to export sidecar files. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
importSidecarForLibrary(libraryId: number, event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.sidecarImporting[libraryId] = true;
|
||||
|
||||
this.sidecarService.bulkImport(libraryId).subscribe({
|
||||
next: (response) => {
|
||||
this.sidecarImporting[libraryId] = false;
|
||||
this.showMessage('success', 'Import Complete', `Imported metadata from ${response.imported} sidecar files.`);
|
||||
},
|
||||
error: (error) => {
|
||||
this.sidecarImporting[libraryId] = false;
|
||||
console.error('Bulk sidecar import failed:', error);
|
||||
this.showMessage('error', 'Import Failed', 'Failed to import from sidecar files. Please try again.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isSidecarExporting(libraryId: number): boolean {
|
||||
return this.sidecarExporting[libraryId] ?? false;
|
||||
}
|
||||
|
||||
isSidecarImporting(libraryId: number): boolean {
|
||||
return this.sidecarImporting[libraryId] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,4 +169,62 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="section-header sidecar-section-header">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-file"></i>
|
||||
Sidecar JSON Files
|
||||
</h3>
|
||||
<p class="section-description">
|
||||
Create external .metadata.json files alongside your books for portable metadata storage and backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section-body">
|
||||
<div class="setting-item">
|
||||
<div class="setting-header">
|
||||
<p-toggleswitch
|
||||
[ngModel]="metadataPersistence.sidecarSettings?.enabled"
|
||||
(onChange)="onSidecarToggle('enabled')">
|
||||
</p-toggleswitch>
|
||||
<label class="setting-label">Enable Sidecar JSON</label>
|
||||
</div>
|
||||
<p class="setting-description">Enable sidecar JSON metadata files. When enabled, metadata can be written to <code>BookName.metadata.json</code> files alongside your books.</p>
|
||||
</div>
|
||||
|
||||
@if (metadataPersistence.sidecarSettings?.enabled) {
|
||||
<div class="setting-item nested-setting">
|
||||
<div class="setting-header">
|
||||
<p-toggleswitch
|
||||
[ngModel]="metadataPersistence.sidecarSettings?.writeOnUpdate"
|
||||
(onChange)="onSidecarToggle('writeOnUpdate')">
|
||||
</p-toggleswitch>
|
||||
<label class="setting-label">Write on Metadata Update</label>
|
||||
</div>
|
||||
<p class="setting-description">Automatically update the sidecar JSON file whenever book metadata is edited.</p>
|
||||
</div>
|
||||
|
||||
<div class="setting-item nested-setting">
|
||||
<div class="setting-header">
|
||||
<p-toggleswitch
|
||||
[ngModel]="metadataPersistence.sidecarSettings?.writeOnScan"
|
||||
(onChange)="onSidecarToggle('writeOnScan')">
|
||||
</p-toggleswitch>
|
||||
<label class="setting-label">Write on Initial Scan</label>
|
||||
</div>
|
||||
<p class="setting-description">Create sidecar JSON files when books are first added to the library during scanning.</p>
|
||||
</div>
|
||||
|
||||
<div class="setting-item nested-setting">
|
||||
<div class="setting-header">
|
||||
<p-toggleswitch
|
||||
[ngModel]="metadataPersistence.sidecarSettings?.includeCoverFile"
|
||||
(onChange)="onSidecarToggle('includeCoverFile')">
|
||||
</p-toggleswitch>
|
||||
<label class="setting-label">Include Cover Image File</label>
|
||||
</div>
|
||||
<p class="setting-description">Also save the cover image as <code>BookName.cover.jpg</code> alongside the sidecar JSON file.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,3 +61,23 @@
|
||||
.filesize-input {
|
||||
@include settings.settings-filesize-input;
|
||||
}
|
||||
|
||||
.sidecar-section-header {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.nested-setting {
|
||||
margin-left: 1.5rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid var(--surface-border);
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--surface-ground);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {AppSettingKey, AppSettings, MetadataPersistenceSettings, SaveToOriginalFileSettings} from '../../../../shared/model/app-settings.model';
|
||||
import {AppSettingKey, AppSettings, MetadataPersistenceSettings, SaveToOriginalFileSettings, SidecarSettings} from '../../../../shared/model/app-settings.model';
|
||||
import {AppSettingsService} from '../../../../shared/service/app-settings.service';
|
||||
import {SettingsHelperService} from '../../../../shared/service/settings-helper.service';
|
||||
import {Observable} from 'rxjs';
|
||||
@@ -42,7 +42,13 @@ export class MetadataPersistenceSettingsComponent implements OnInit {
|
||||
}
|
||||
},
|
||||
convertCbrCb7ToCbz: false,
|
||||
moveFilesToLibraryPattern: false
|
||||
moveFilesToLibraryPattern: false,
|
||||
sidecarSettings: {
|
||||
enabled: false,
|
||||
writeOnUpdate: false,
|
||||
writeOnScan: false,
|
||||
includeCoverFile: false
|
||||
}
|
||||
};
|
||||
|
||||
private readonly appSettingsService = inject(AppSettingsService);
|
||||
@@ -55,8 +61,8 @@ export class MetadataPersistenceSettingsComponent implements OnInit {
|
||||
}
|
||||
|
||||
onPersistenceToggle(key: keyof MetadataPersistenceSettings): void {
|
||||
if (key !== 'saveToOriginalFile') {
|
||||
this.metadataPersistence[key] = !this.metadataPersistence[key];
|
||||
if (key !== 'saveToOriginalFile' && key !== 'sidecarSettings') {
|
||||
(this.metadataPersistence as any)[key] = !this.metadataPersistence[key];
|
||||
this.settingsHelper.saveSetting(AppSettingKey.METADATA_PERSISTENCE_SETTINGS, this.metadataPersistence);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +77,13 @@ export class MetadataPersistenceSettingsComponent implements OnInit {
|
||||
this.settingsHelper.saveSetting(AppSettingKey.METADATA_PERSISTENCE_SETTINGS, this.metadataPersistence);
|
||||
}
|
||||
|
||||
onSidecarToggle(key: keyof SidecarSettings): void {
|
||||
if (this.metadataPersistence.sidecarSettings) {
|
||||
(this.metadataPersistence.sidecarSettings as any)[key] = !this.metadataPersistence.sidecarSettings[key];
|
||||
this.settingsHelper.saveSetting(AppSettingKey.METADATA_PERSISTENCE_SETTINGS, this.metadataPersistence);
|
||||
}
|
||||
}
|
||||
|
||||
private loadSettings(): void {
|
||||
this.appSettings$.pipe(
|
||||
filter((settings): settings is AppSettings => !!settings),
|
||||
@@ -107,6 +120,12 @@ export class MetadataPersistenceSettingsComponent implements OnInit {
|
||||
enabled: persistenceSettings.saveToOriginalFile?.audiobook?.enabled ?? false,
|
||||
maxFileSizeInMb: persistenceSettings.saveToOriginalFile?.audiobook?.maxFileSizeInMb ?? 1000
|
||||
}
|
||||
},
|
||||
sidecarSettings: {
|
||||
enabled: persistenceSettings.sidecarSettings?.enabled ?? false,
|
||||
writeOnUpdate: persistenceSettings.sidecarSettings?.writeOnUpdate ?? false,
|
||||
writeOnScan: persistenceSettings.sidecarSettings?.writeOnScan ?? false,
|
||||
includeCoverFile: persistenceSettings.sidecarSettings?.includeCoverFile ?? false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,10 +112,18 @@ export interface SaveToOriginalFileSettings {
|
||||
audiobook: FormatWriteSettings;
|
||||
}
|
||||
|
||||
export interface SidecarSettings {
|
||||
enabled: boolean;
|
||||
writeOnUpdate: boolean;
|
||||
writeOnScan: boolean;
|
||||
includeCoverFile: boolean;
|
||||
}
|
||||
|
||||
export interface MetadataPersistenceSettings {
|
||||
moveFilesToLibraryPattern: boolean;
|
||||
saveToOriginalFile: SaveToOriginalFileSettings;
|
||||
convertCbrCb7ToCbz: boolean;
|
||||
sidecarSettings?: SidecarSettings;
|
||||
}
|
||||
|
||||
export interface ReviewProviderConfig {
|
||||
|
||||
Reference in New Issue
Block a user