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;