feat(sidecar): add sidecar JSON metadata file support (#2657)

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2026-02-08 09:31:18 -07:00
committed by GitHub
parent c1c72ea7ba
commit e462b6c197
62 changed files with 2177 additions and 146 deletions

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -13,6 +13,7 @@ public class MetadataPersistenceSettings {
private SaveToOriginalFile saveToOriginalFile;
private boolean convertCbrCb7ToCbz;
private boolean moveFilesToLibraryPattern;
private SidecarSettings sidecarSettings;
@Data
@Builder

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -0,0 +1,9 @@
package org.booklore.model.enums;
public enum MetadataSource {
EMBEDDED,
SIDECAR,
PREFER_SIDECAR,
PREFER_EMBEDDED,
NONE
}

View File

@@ -0,0 +1,9 @@
package org.booklore.model.enums;
public enum SidecarSyncStatus {
IN_SYNC,
OUTDATED,
MISSING,
CONFLICT,
NOT_APPLICABLE
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

@@ -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 {

View File

@@ -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();
}
}

View File

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

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE library
ADD COLUMN metadata_source VARCHAR(20) DEFAULT 'EMBEDDED';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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') {

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View 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`, {}
);
}
}

View File

@@ -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>

View File

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

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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
}
};
}

View File

@@ -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 {