mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(sidecar): add sidecar JSON metadata file support (#2657)
Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package org.booklore.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.booklore.config.security.annotation.CheckBookAccess;
|
||||
import org.booklore.model.dto.sidecar.SidecarMetadata;
|
||||
import org.booklore.model.enums.SidecarSyncStatus;
|
||||
import org.booklore.service.metadata.sidecar.SidecarService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Tag(name = "Sidecar Metadata", description = "Endpoints for managing sidecar JSON metadata files")
|
||||
@RequestMapping("/api/v1")
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
public class SidecarController {
|
||||
|
||||
private final SidecarService sidecarService;
|
||||
|
||||
@Operation(summary = "Get sidecar content", description = "Get the content of the sidecar JSON file for a book")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Sidecar content returned successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "Book or sidecar file not found")
|
||||
})
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
@GetMapping("/books/{bookId}/sidecar")
|
||||
public ResponseEntity<SidecarMetadata> getSidecarContent(@Parameter(description = "Book ID") @PathVariable Long bookId) {
|
||||
Optional<SidecarMetadata> sidecar = sidecarService.getSidecarContent(bookId);
|
||||
return sidecar.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@Operation(summary = "Get sidecar sync status", description = "Get the synchronization status between database and sidecar file")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Sync status returned successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "Book not found")
|
||||
})
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
@GetMapping("/books/{bookId}/sidecar/status")
|
||||
public ResponseEntity<Map<String, SidecarSyncStatus>> getSyncStatus(@Parameter(description = "Book ID") @PathVariable Long bookId) {
|
||||
SidecarSyncStatus status = sidecarService.getSyncStatus(bookId);
|
||||
return ResponseEntity.ok(Map.of("status", status));
|
||||
}
|
||||
|
||||
@Operation(summary = "Export metadata to sidecar", description = "Export book metadata to a sidecar JSON file")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Metadata exported successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "Book not found")
|
||||
})
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
@PostMapping("/books/{bookId}/sidecar/export")
|
||||
public ResponseEntity<Map<String, String>> exportToSidecar(@Parameter(description = "Book ID") @PathVariable Long bookId) {
|
||||
sidecarService.exportToSidecar(bookId);
|
||||
return ResponseEntity.ok(Map.of("message", "Sidecar metadata exported successfully"));
|
||||
}
|
||||
|
||||
@Operation(summary = "Import metadata from sidecar", description = "Import book metadata from a sidecar JSON file")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Metadata imported successfully"),
|
||||
@ApiResponse(responseCode = "404", description = "Book or sidecar file not found")
|
||||
})
|
||||
@CheckBookAccess(bookIdParam = "bookId")
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
@PostMapping("/books/{bookId}/sidecar/import")
|
||||
public ResponseEntity<Map<String, String>> importFromSidecar(@Parameter(description = "Book ID") @PathVariable Long bookId) {
|
||||
sidecarService.importFromSidecar(bookId);
|
||||
return ResponseEntity.ok(Map.of("message", "Sidecar metadata imported successfully"));
|
||||
}
|
||||
|
||||
@Operation(summary = "Bulk export sidecar for library", description = "Generate sidecar JSON files for all books in a library")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Bulk export completed"),
|
||||
@ApiResponse(responseCode = "404", description = "Library not found")
|
||||
})
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
@PostMapping("/libraries/{libraryId}/sidecar/export-all")
|
||||
public ResponseEntity<Map<String, Object>> bulkExport(@Parameter(description = "Library ID") @PathVariable Long libraryId) {
|
||||
int exported = sidecarService.bulkExport(libraryId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Bulk export completed",
|
||||
"exported", exported
|
||||
));
|
||||
}
|
||||
|
||||
@Operation(summary = "Bulk import sidecar for library", description = "Import metadata from all sidecar JSON files in a library")
|
||||
@ApiResponses({
|
||||
@ApiResponse(responseCode = "200", description = "Bulk import completed"),
|
||||
@ApiResponse(responseCode = "404", description = "Library not found")
|
||||
})
|
||||
@PreAuthorize("@securityUtil.canEditMetadata() or @securityUtil.isAdmin()")
|
||||
@PostMapping("/libraries/{libraryId}/sidecar/import-all")
|
||||
public ResponseEntity<Map<String, Object>> bulkImport(@Parameter(description = "Library ID") @PathVariable Long libraryId) {
|
||||
int imported = sidecarService.bulkImport(libraryId);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"message", "Bulk import completed",
|
||||
"imported", imported
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.List;
|
||||
@@ -9,6 +10,7 @@ import java.util.List;
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AudiobookMetadata {
|
||||
private Long durationSeconds;
|
||||
private Integer bitrate;
|
||||
@@ -23,6 +25,7 @@ public class AudiobookMetadata {
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ChapterInfo {
|
||||
private Integer index;
|
||||
private String title;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.booklore.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.*;
|
||||
|
||||
import java.util.Set;
|
||||
@@ -9,6 +10,7 @@ import java.util.Set;
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ComicMetadata {
|
||||
private String issueNumber;
|
||||
private String volumeName;
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.booklore.model.dto;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.IconType;
|
||||
import org.booklore.model.enums.LibraryOrganizationMode;
|
||||
import org.booklore.model.enums.MetadataSource;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
@@ -24,5 +25,6 @@ public class Library {
|
||||
private List<BookFileType> formatPriority;
|
||||
private List<BookFileType> allowedFormats;
|
||||
private LibraryOrganizationMode organizationMode;
|
||||
private MetadataSource metadataSource;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.booklore.model.dto.request;
|
||||
import org.booklore.model.dto.LibraryPath;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.IconType;
|
||||
import org.booklore.model.enums.MetadataSource;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
@@ -28,4 +29,5 @@ public class CreateLibraryRequest {
|
||||
private boolean watch;
|
||||
private List<BookFileType> formatPriority;
|
||||
private List<BookFileType> allowedFormats;
|
||||
private MetadataSource metadataSource;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public class MetadataPersistenceSettings {
|
||||
private SaveToOriginalFile saveToOriginalFile;
|
||||
private boolean convertCbrCb7ToCbz;
|
||||
private boolean moveFilesToLibraryPattern;
|
||||
private SidecarSettings sidecarSettings;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.booklore.model.dto.settings;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SidecarSettings {
|
||||
private boolean enabled;
|
||||
private boolean writeOnUpdate;
|
||||
private boolean writeOnScan;
|
||||
private boolean includeCoverFile;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarBookMetadata {
|
||||
private String title;
|
||||
private String subtitle;
|
||||
private Set<String> authors;
|
||||
private String publisher;
|
||||
private LocalDate publishedDate;
|
||||
private String description;
|
||||
private String isbn10;
|
||||
private String isbn13;
|
||||
private String language;
|
||||
private Integer pageCount;
|
||||
private Set<String> categories;
|
||||
private Set<String> moods;
|
||||
private Set<String> tags;
|
||||
private SidecarSeries series;
|
||||
private SidecarIdentifiers identifiers;
|
||||
private SidecarRatings ratings;
|
||||
private Integer ageRating;
|
||||
private String contentRating;
|
||||
private String narrator;
|
||||
private Boolean abridged;
|
||||
private ComicMetadata comicMetadata;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarCoverInfo {
|
||||
private String source;
|
||||
private String path;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarIdentifiers {
|
||||
private String asin;
|
||||
private String goodreadsId;
|
||||
private String googleId;
|
||||
private String hardcoverId;
|
||||
private String comicvineId;
|
||||
private String lubimyczytacId;
|
||||
private String ranobedbId;
|
||||
private String audibleId;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarMetadata {
|
||||
@Builder.Default
|
||||
private String version = "1.0";
|
||||
private Instant generatedAt;
|
||||
@Builder.Default
|
||||
private String generatedBy = "booklore";
|
||||
private SidecarBookMetadata metadata;
|
||||
private SidecarCoverInfo cover;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarRating {
|
||||
private Double average;
|
||||
private Integer count;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarRatings {
|
||||
private SidecarRating amazon;
|
||||
private SidecarRating goodreads;
|
||||
private SidecarRating hardcover;
|
||||
private SidecarRating lubimyczytac;
|
||||
private SidecarRating ranobedb;
|
||||
private SidecarRating audible;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.booklore.model.dto.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SidecarSeries {
|
||||
private String name;
|
||||
private Float number;
|
||||
private Integer total;
|
||||
}
|
||||
@@ -331,7 +331,7 @@ public class BookMetadataEntity {
|
||||
@JsonIgnore
|
||||
private BookEntity book;
|
||||
|
||||
@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OneToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "book_id", referencedColumnName = "book_id", insertable = false, updatable = false)
|
||||
private ComicMetadataEntity comicMetadata;
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ public class ComicMetadataEntity {
|
||||
@Builder.Default
|
||||
private Set<ComicLocationEntity> locations = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "comicMetadata", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
@OneToMany(mappedBy = "comicMetadata", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
|
||||
@Fetch(FetchMode.SUBSELECT)
|
||||
@Builder.Default
|
||||
private Set<ComicCreatorMappingEntity> creatorMappings = new HashSet<>();
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.booklore.model.dto.Sort;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.IconType;
|
||||
import org.booklore.model.enums.LibraryOrganizationMode;
|
||||
import org.booklore.model.enums.MetadataSource;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
@@ -64,4 +65,9 @@ public class LibraryEntity {
|
||||
@Builder.Default
|
||||
private LibraryOrganizationMode organizationMode = LibraryOrganizationMode.AUTO_DETECT;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "metadata_source")
|
||||
@Builder.Default
|
||||
private MetadataSource metadataSource = MetadataSource.EMBEDDED;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.booklore.model.enums;
|
||||
|
||||
public enum MetadataSource {
|
||||
EMBEDDED,
|
||||
SIDECAR,
|
||||
PREFER_SIDECAR,
|
||||
PREFER_EMBEDDED,
|
||||
NONE
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.booklore.model.enums;
|
||||
|
||||
public enum SidecarSyncStatus {
|
||||
IN_SYNC,
|
||||
OUTDATED,
|
||||
MISSING,
|
||||
CONFLICT,
|
||||
NOT_APPLICABLE
|
||||
}
|
||||
@@ -62,6 +62,10 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByLibraryId(@Param("libraryId") Long libraryId);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id = :libraryId AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllByLibraryIdWithFiles(@Param("libraryId") Long libraryId);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "shelves", "libraryPath", "bookFiles"})
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library.id IN :libraryIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByLibraryIds(@Param("libraryIds") Collection<Long> libraryIds);
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.booklore.model.entity.UserBookProgressEntity;
|
||||
import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.repository.*;
|
||||
import org.booklore.repository.BookFileRepository;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.booklore.service.progress.ReadingProgressService;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -62,6 +63,7 @@ public class BookService {
|
||||
private final MonitoringRegistrationService monitoringRegistrationService;
|
||||
private final BookUpdateService bookUpdateService;
|
||||
private final EbookViewerPreferenceRepository ebookViewerPreferencesRepository;
|
||||
private final SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
|
||||
public List<Book> getBookDTOs(boolean includeDescription) {
|
||||
@@ -346,6 +348,12 @@ public class BookService {
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
deleteEmptyParentDirsUpToLibraryFolders(fullFilePath.getParent(), libraryRoots);
|
||||
|
||||
try {
|
||||
sidecarMetadataWriter.deleteSidecarFiles(fullFilePath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to delete sidecar files for: {}", fullFilePath, e);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete book file: {}", fullFilePath, e);
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.booklore.repository.BookAdditionalFileRepository;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.LibraryRepository;
|
||||
import org.booklore.service.NotificationService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
@@ -44,6 +45,7 @@ public class FileMoveService {
|
||||
private final NotificationService notificationService;
|
||||
private final EntityManager entityManager;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
private final SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
|
||||
public void bulkMoveFiles(FileMoveRequest request) {
|
||||
@@ -220,6 +222,12 @@ public class FileMoveService {
|
||||
fileMoveHelper.deleteEmptyParentDirsUpToLibraryFolders(sourceParent, libraryRoots);
|
||||
}
|
||||
|
||||
try {
|
||||
sidecarMetadataWriter.moveSidecarFiles(currentPrimaryFilePath, newFilePath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to move sidecar files for book ID {}: {}", bookId, e.getMessage());
|
||||
}
|
||||
|
||||
entityManager.clear();
|
||||
|
||||
BookEntity fresh = bookRepository.findById(bookId).orElseThrow();
|
||||
@@ -373,6 +381,12 @@ public class FileMoveService {
|
||||
fileMoveHelper.deleteEmptyParentDirsUpToLibraryFolders(sourceParent, libraryRoots);
|
||||
}
|
||||
|
||||
try {
|
||||
sidecarMetadataWriter.moveSidecarFiles(currentPrimaryFilePath, expectedPrimaryFilePath);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to move sidecar files for book ID {}: {}", bookEntity.getId(), e.getMessage());
|
||||
}
|
||||
|
||||
if (isLibraryMonitoredWhenCalled) {
|
||||
// Ensure any file system events from the move and cleanup are drained/ignored while we are still unregistered
|
||||
sleep(EVENT_DRAIN_TIMEOUT_MS);
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.file.FileFingerprint;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.FileService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
@@ -27,6 +28,7 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
|
||||
protected final BookMapper bookMapper;
|
||||
protected final MetadataMatchService metadataMatchService;
|
||||
protected final FileService fileService;
|
||||
protected final SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
|
||||
protected AbstractFileProcessor(BookRepository bookRepository,
|
||||
@@ -34,13 +36,15 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
|
||||
BookCreatorService bookCreatorService,
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService) {
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter) {
|
||||
this.bookRepository = bookRepository;
|
||||
this.bookAdditionalFileRepository = bookAdditionalFileRepository;
|
||||
this.bookCreatorService = bookCreatorService;
|
||||
this.bookMapper = bookMapper;
|
||||
this.metadataMatchService = metadataMatchService;
|
||||
this.fileService = fileService;
|
||||
this.sidecarMetadataWriter = sidecarMetadataWriter;
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
@@ -59,6 +63,15 @@ public abstract class AbstractFileProcessor implements BookFileProcessor {
|
||||
entity.getPrimaryBookFile().setCurrentHash(hash);
|
||||
entity.setMetadataMatchScore(metadataMatchService.calculateMatchScore(entity));
|
||||
bookCreatorService.saveConnections(entity);
|
||||
|
||||
if (sidecarMetadataWriter.isWriteOnScanEnabled()) {
|
||||
try {
|
||||
sidecarMetadataWriter.writeSidecarMetadata(entity);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to write sidecar metadata for book ID {}: {}", entity.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return bookMapper.toBook(entity);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.AudiobookMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -42,8 +43,9 @@ public class AudiobookProcessor extends AbstractFileProcessor implements BookFil
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
AudiobookMetadataExtractor audiobookMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.audiobookMetadataExtractor = audiobookMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.Azw3MetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -38,8 +39,9 @@ public class Azw3Processor extends AbstractFileProcessor implements BookFileProc
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
Azw3MetadataExtractor azw3MetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.azw3MetadataExtractor = azw3MetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.CbxMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.ArchiveUtils;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -53,8 +54,9 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
CbxMetadataExtractor cbxMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.cbxMetadataExtractor = cbxMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.EpubMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -40,8 +41,9 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
EpubMetadataExtractor epubMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.epubMetadataExtractor = epubMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.Fb2MetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -38,8 +39,9 @@ public class Fb2Processor extends AbstractFileProcessor implements BookFileProce
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
Fb2MetadataExtractor fb2MetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.fb2MetadataExtractor = fb2MetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.MobiMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -38,8 +39,9 @@ public class MobiProcessor extends AbstractFileProcessor implements BookFileProc
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
MobiMetadataExtractor mobiMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.mobiMetadataExtractor = mobiMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.PdfMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
import org.booklore.util.FileUtils;
|
||||
@@ -42,8 +43,9 @@ public class PdfProcessor extends AbstractFileProcessor implements BookFileProce
|
||||
BookMapper bookMapper,
|
||||
FileService fileService,
|
||||
MetadataMatchService metadataMatchService,
|
||||
SidecarMetadataWriter sidecarMetadataWriter,
|
||||
PdfMetadataExtractor pdfMetadataExtractor) {
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService);
|
||||
super(bookRepository, bookAdditionalFileRepository, bookCreatorService, bookMapper, fileService, metadataMatchService, sidecarMetadataWriter);
|
||||
this.pdfMetadataExtractor = pdfMetadataExtractor;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,9 @@ public class LibraryService {
|
||||
library.setWatch(request.isWatch());
|
||||
library.setFormatPriority(request.getFormatPriority());
|
||||
library.setAllowedFormats(request.getAllowedFormats());
|
||||
if (request.getMetadataSource() != null) {
|
||||
library.setMetadataSource(request.getMetadataSource());
|
||||
}
|
||||
|
||||
Set<String> currentPaths = library.getLibraryPaths().stream()
|
||||
.map(LibraryPathEntity::getPath)
|
||||
@@ -173,6 +176,7 @@ public class LibraryService {
|
||||
.watch(request.isWatch())
|
||||
.formatPriority(request.getFormatPriority())
|
||||
.allowedFormats(request.getAllowedFormats())
|
||||
.metadataSource(request.getMetadataSource())
|
||||
.users(List.of(user.get()))
|
||||
.build();
|
||||
|
||||
@@ -251,13 +255,13 @@ public class LibraryService {
|
||||
return libraries.stream().map(libraryMapper::toLibrary).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteLibrary(long id) {
|
||||
LibraryEntity library = libraryRepository.findById(id).orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(id));
|
||||
library.getLibraryPaths().forEach(libraryPath -> {
|
||||
Path path = Paths.get(libraryPath.getPath());
|
||||
monitoringService.unregisterLibrary(id);
|
||||
});
|
||||
Set<Long> bookIds = library.getBookEntities().stream().map(BookEntity::getId).collect(Collectors.toSet());
|
||||
if (!libraryRepository.existsById(id)) {
|
||||
throw ApiError.LIBRARY_NOT_FOUND.createException(id);
|
||||
}
|
||||
monitoringService.unregisterLibrary(id);
|
||||
Set<Long> bookIds = bookRepository.findBookIdsByLibraryId(id);
|
||||
fileService.deleteBookCovers(bookIds);
|
||||
libraryRepository.deleteById(id);
|
||||
log.info("Library deleted successfully: {}", id);
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.booklore.repository.*;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.service.file.FileFingerprint;
|
||||
import org.booklore.service.file.FileMoveService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.metadata.writer.MetadataWriterFactory;
|
||||
import org.booklore.util.BookCoverUtils;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -56,6 +57,7 @@ public class BookMetadataUpdater {
|
||||
private final MetadataWriterFactory metadataWriterFactory;
|
||||
private final BookReviewUpdateService bookReviewUpdateService;
|
||||
private final FileMoveService fileMoveService;
|
||||
private final SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
@Transactional
|
||||
public void setBookMetadata(MetadataUpdateContext context) {
|
||||
@@ -138,6 +140,14 @@ public class BookMetadataUpdater {
|
||||
});
|
||||
}
|
||||
|
||||
if (sidecarMetadataWriter.isWriteOnUpdateEnabled()) {
|
||||
try {
|
||||
sidecarMetadataWriter.writeSidecarMetadata(bookEntity);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to write sidecar metadata for book ID {}: {}", bookId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
boolean moveFilesToLibraryPattern = settings.isMoveFilesToLibraryPattern();
|
||||
if (moveFilesToLibraryPattern && primaryFile != null) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.ComicMetadata;
|
||||
import org.booklore.model.dto.sidecar.*;
|
||||
import org.booklore.model.entity.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SidecarMetadataMapper {
|
||||
|
||||
public SidecarMetadata toSidecarMetadata(BookMetadataEntity entity, String coverFileName) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SidecarBookMetadata bookMetadata = SidecarBookMetadata.builder()
|
||||
.title(entity.getTitle())
|
||||
.subtitle(entity.getSubtitle())
|
||||
.authors(extractNames(entity.getAuthors()))
|
||||
.publisher(entity.getPublisher())
|
||||
.publishedDate(entity.getPublishedDate())
|
||||
.description(entity.getDescription())
|
||||
.isbn10(entity.getIsbn10())
|
||||
.isbn13(entity.getIsbn13())
|
||||
.language(entity.getLanguage())
|
||||
.pageCount(entity.getPageCount())
|
||||
.categories(extractCategoryNames(entity.getCategories()))
|
||||
.moods(extractMoodNames(entity.getMoods()))
|
||||
.tags(extractTagNames(entity.getTags()))
|
||||
.series(buildSeries(entity))
|
||||
.identifiers(buildIdentifiers(entity))
|
||||
.ratings(buildRatings(entity))
|
||||
.ageRating(entity.getAgeRating())
|
||||
.contentRating(entity.getContentRating())
|
||||
.narrator(entity.getNarrator())
|
||||
.abridged(entity.getAbridged())
|
||||
.comicMetadata(buildComicMetadata(entity.getComicMetadata()))
|
||||
.build();
|
||||
|
||||
SidecarCoverInfo coverInfo = null;
|
||||
if (StringUtils.hasText(coverFileName)) {
|
||||
coverInfo = SidecarCoverInfo.builder()
|
||||
.source("external")
|
||||
.path(coverFileName)
|
||||
.build();
|
||||
}
|
||||
|
||||
return SidecarMetadata.builder()
|
||||
.version("1.0")
|
||||
.generatedAt(Instant.now())
|
||||
.generatedBy("booklore")
|
||||
.metadata(bookMetadata)
|
||||
.cover(coverInfo)
|
||||
.build();
|
||||
}
|
||||
|
||||
public BookMetadata toBookMetadata(SidecarMetadata sidecar) {
|
||||
if (sidecar == null || sidecar.getMetadata() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
SidecarBookMetadata m = sidecar.getMetadata();
|
||||
|
||||
BookMetadata.BookMetadataBuilder builder = BookMetadata.builder()
|
||||
.title(m.getTitle())
|
||||
.subtitle(m.getSubtitle())
|
||||
.authors(m.getAuthors())
|
||||
.publisher(m.getPublisher())
|
||||
.publishedDate(m.getPublishedDate())
|
||||
.description(m.getDescription())
|
||||
.isbn10(m.getIsbn10())
|
||||
.isbn13(m.getIsbn13())
|
||||
.language(m.getLanguage())
|
||||
.pageCount(m.getPageCount())
|
||||
.categories(m.getCategories())
|
||||
.moods(m.getMoods())
|
||||
.tags(m.getTags())
|
||||
.ageRating(m.getAgeRating())
|
||||
.contentRating(m.getContentRating())
|
||||
.narrator(m.getNarrator())
|
||||
.abridged(m.getAbridged())
|
||||
.comicMetadata(m.getComicMetadata());
|
||||
|
||||
if (m.getSeries() != null) {
|
||||
builder.seriesName(m.getSeries().getName())
|
||||
.seriesNumber(m.getSeries().getNumber())
|
||||
.seriesTotal(m.getSeries().getTotal());
|
||||
}
|
||||
|
||||
if (m.getIdentifiers() != null) {
|
||||
SidecarIdentifiers ids = m.getIdentifiers();
|
||||
builder.asin(ids.getAsin())
|
||||
.goodreadsId(ids.getGoodreadsId())
|
||||
.googleId(ids.getGoogleId())
|
||||
.hardcoverId(ids.getHardcoverId())
|
||||
.comicvineId(ids.getComicvineId())
|
||||
.lubimyczytacId(ids.getLubimyczytacId())
|
||||
.ranobedbId(ids.getRanobedbId())
|
||||
.audibleId(ids.getAudibleId());
|
||||
}
|
||||
|
||||
if (m.getRatings() != null) {
|
||||
SidecarRatings r = m.getRatings();
|
||||
if (r.getAmazon() != null) {
|
||||
builder.amazonRating(r.getAmazon().getAverage())
|
||||
.amazonReviewCount(r.getAmazon().getCount());
|
||||
}
|
||||
if (r.getGoodreads() != null) {
|
||||
builder.goodreadsRating(r.getGoodreads().getAverage())
|
||||
.goodreadsReviewCount(r.getGoodreads().getCount());
|
||||
}
|
||||
if (r.getHardcover() != null) {
|
||||
builder.hardcoverRating(r.getHardcover().getAverage())
|
||||
.hardcoverReviewCount(r.getHardcover().getCount());
|
||||
}
|
||||
if (r.getLubimyczytac() != null) {
|
||||
builder.lubimyczytacRating(r.getLubimyczytac().getAverage());
|
||||
}
|
||||
if (r.getRanobedb() != null) {
|
||||
builder.ranobedbRating(r.getRanobedb().getAverage());
|
||||
}
|
||||
if (r.getAudible() != null) {
|
||||
builder.audibleRating(r.getAudible().getAverage())
|
||||
.audibleReviewCount(r.getAudible().getCount());
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public String getCoverFileName(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return baseName + ".cover.jpg";
|
||||
}
|
||||
|
||||
private Set<String> extractNames(Set<AuthorEntity> entities) {
|
||||
if (entities == null) return null;
|
||||
return entities.stream()
|
||||
.map(AuthorEntity::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Set<String> extractCategoryNames(Set<CategoryEntity> entities) {
|
||||
if (entities == null) return null;
|
||||
return entities.stream()
|
||||
.map(CategoryEntity::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Set<String> extractMoodNames(Set<MoodEntity> entities) {
|
||||
if (entities == null) return null;
|
||||
return entities.stream()
|
||||
.map(MoodEntity::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private Set<String> extractTagNames(Set<TagEntity> entities) {
|
||||
if (entities == null) return null;
|
||||
return entities.stream()
|
||||
.map(TagEntity::getName)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private SidecarSeries buildSeries(BookMetadataEntity entity) {
|
||||
if (entity.getSeriesName() == null && entity.getSeriesNumber() == null && entity.getSeriesTotal() == null) {
|
||||
return null;
|
||||
}
|
||||
return SidecarSeries.builder()
|
||||
.name(entity.getSeriesName())
|
||||
.number(entity.getSeriesNumber())
|
||||
.total(entity.getSeriesTotal())
|
||||
.build();
|
||||
}
|
||||
|
||||
private SidecarIdentifiers buildIdentifiers(BookMetadataEntity entity) {
|
||||
return SidecarIdentifiers.builder()
|
||||
.asin(entity.getAsin())
|
||||
.goodreadsId(entity.getGoodreadsId())
|
||||
.googleId(entity.getGoogleId())
|
||||
.hardcoverId(entity.getHardcoverId())
|
||||
.comicvineId(entity.getComicvineId())
|
||||
.lubimyczytacId(entity.getLubimyczytacId())
|
||||
.ranobedbId(entity.getRanobedbId())
|
||||
.audibleId(entity.getAudibleId())
|
||||
.build();
|
||||
}
|
||||
|
||||
private SidecarRatings buildRatings(BookMetadataEntity entity) {
|
||||
return SidecarRatings.builder()
|
||||
.amazon(buildRating(entity.getAmazonRating(), entity.getAmazonReviewCount()))
|
||||
.goodreads(buildRating(entity.getGoodreadsRating(), entity.getGoodreadsReviewCount()))
|
||||
.hardcover(buildRating(entity.getHardcoverRating(), entity.getHardcoverReviewCount()))
|
||||
.lubimyczytac(buildRating(entity.getLubimyczytacRating(), null))
|
||||
.ranobedb(buildRating(entity.getRanobedbRating(), null))
|
||||
.audible(buildRating(entity.getAudibleRating(), entity.getAudibleReviewCount()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private SidecarRating buildRating(Double average, Integer count) {
|
||||
if (average == null && count == null) {
|
||||
return null;
|
||||
}
|
||||
return SidecarRating.builder()
|
||||
.average(average)
|
||||
.count(count)
|
||||
.build();
|
||||
}
|
||||
|
||||
private ComicMetadata buildComicMetadata(ComicMetadataEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
return ComicMetadata.builder()
|
||||
.issueNumber(entity.getIssueNumber())
|
||||
.volumeName(entity.getVolumeName())
|
||||
.volumeNumber(entity.getVolumeNumber())
|
||||
.storyArc(entity.getStoryArc())
|
||||
.storyArcNumber(entity.getStoryArcNumber())
|
||||
.alternateSeries(entity.getAlternateSeries())
|
||||
.alternateIssue(entity.getAlternateIssue())
|
||||
.imprint(entity.getImprint())
|
||||
.format(entity.getFormat())
|
||||
.blackAndWhite(entity.getBlackAndWhite())
|
||||
.manga(entity.getManga())
|
||||
.readingDirection(entity.getReadingDirection())
|
||||
.webLink(entity.getWebLink())
|
||||
.notes(entity.getNotes())
|
||||
.characters(entity.getCharacters() != null ?
|
||||
entity.getCharacters().stream().map(ComicCharacterEntity::getName).collect(Collectors.toSet()) : null)
|
||||
.teams(entity.getTeams() != null ?
|
||||
entity.getTeams().stream().map(ComicTeamEntity::getName).collect(Collectors.toSet()) : null)
|
||||
.locations(entity.getLocations() != null ?
|
||||
entity.getLocations().stream().map(ComicLocationEntity::getName).collect(Collectors.toSet()) : null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.sidecar.SidecarMetadata;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.model.enums.SidecarSyncStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SidecarMetadataReader {
|
||||
|
||||
private final SidecarMetadataMapper mapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SidecarMetadataReader(SidecarMetadataMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
}
|
||||
|
||||
public Optional<SidecarMetadata> readSidecarMetadata(Path bookPath) {
|
||||
if (bookPath == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Path sidecarPath = getSidecarPath(bookPath);
|
||||
if (!Files.exists(sidecarPath)) {
|
||||
log.debug("No sidecar file found at: {}", sidecarPath);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
String json = Files.readString(sidecarPath);
|
||||
SidecarMetadata metadata = objectMapper.readValue(json, SidecarMetadata.class);
|
||||
log.debug("Read sidecar metadata from: {}", sidecarPath);
|
||||
return Optional.of(metadata);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read sidecar metadata from {}: {}", sidecarPath, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] readSidecarCover(Path bookPath) {
|
||||
if (bookPath == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Path coverPath = getCoverPath(bookPath);
|
||||
if (!Files.exists(coverPath)) {
|
||||
log.debug("No sidecar cover file found at: {}", coverPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Files.readAllBytes(coverPath);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read sidecar cover from {}: {}", coverPath, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public SidecarSyncStatus getSyncStatus(BookEntity book) {
|
||||
if (book == null || book.getPrimaryBookFile() == null) {
|
||||
return SidecarSyncStatus.NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null) {
|
||||
return SidecarSyncStatus.NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
if (!sidecarExists(bookPath)) {
|
||||
return SidecarSyncStatus.MISSING;
|
||||
}
|
||||
|
||||
Optional<SidecarMetadata> sidecarOpt = readSidecarMetadata(bookPath);
|
||||
if (sidecarOpt.isEmpty()) {
|
||||
return SidecarSyncStatus.MISSING;
|
||||
}
|
||||
|
||||
SidecarMetadata sidecar = sidecarOpt.get();
|
||||
BookMetadataEntity dbMetadata = book.getMetadata();
|
||||
|
||||
if (dbMetadata == null) {
|
||||
return SidecarSyncStatus.CONFLICT;
|
||||
}
|
||||
|
||||
// Check content first - if metadata matches, it's in sync regardless of timestamps
|
||||
if (!isMetadataDifferent(sidecar, dbMetadata)) {
|
||||
return SidecarSyncStatus.IN_SYNC;
|
||||
}
|
||||
|
||||
// Content differs - use timestamps to determine which is newer
|
||||
if (sidecar.getGeneratedAt() != null && book.getMetadataUpdatedAt() != null) {
|
||||
if (book.getMetadataUpdatedAt().isAfter(sidecar.getGeneratedAt())) {
|
||||
return SidecarSyncStatus.OUTDATED; // DB is newer, sidecar needs update
|
||||
}
|
||||
}
|
||||
|
||||
return SidecarSyncStatus.CONFLICT; // Sidecar is newer or timestamps unavailable
|
||||
}
|
||||
|
||||
public boolean sidecarExists(Path bookPath) {
|
||||
if (bookPath == null) {
|
||||
return false;
|
||||
}
|
||||
Path sidecarPath = getSidecarPath(bookPath);
|
||||
return Files.exists(sidecarPath);
|
||||
}
|
||||
|
||||
public Path getSidecarPath(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return bookPath.getParent().resolve(baseName + ".metadata.json");
|
||||
}
|
||||
|
||||
public Path getCoverPath(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return bookPath.getParent().resolve(baseName + ".cover.jpg");
|
||||
}
|
||||
|
||||
private boolean isMetadataDifferent(SidecarMetadata sidecar, BookMetadataEntity db) {
|
||||
if (sidecar.getMetadata() == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var sm = sidecar.getMetadata();
|
||||
|
||||
if (!nullSafeEquals(sm.getTitle(), db.getTitle())) return true;
|
||||
if (!nullSafeEquals(sm.getSubtitle(), db.getSubtitle())) return true;
|
||||
if (!nullSafeEquals(sm.getPublisher(), db.getPublisher())) return true;
|
||||
if (!nullSafeEquals(sm.getDescription(), db.getDescription())) return true;
|
||||
if (!nullSafeEquals(sm.getIsbn10(), db.getIsbn10())) return true;
|
||||
if (!nullSafeEquals(sm.getIsbn13(), db.getIsbn13())) return true;
|
||||
if (!nullSafeEquals(sm.getLanguage(), db.getLanguage())) return true;
|
||||
if (!nullSafeEquals(sm.getPageCount(), db.getPageCount())) return true;
|
||||
|
||||
if (sm.getSeries() != null) {
|
||||
if (!nullSafeEquals(sm.getSeries().getName(), db.getSeriesName())) return true;
|
||||
if (!nullSafeEquals(sm.getSeries().getNumber(), db.getSeriesNumber())) return true;
|
||||
if (!nullSafeEquals(sm.getSeries().getTotal(), db.getSeriesTotal())) return true;
|
||||
} else if (db.getSeriesName() != null || db.getSeriesNumber() != null || db.getSeriesTotal() != null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean nullSafeEquals(Object a, Object b) {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
return a.equals(b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.model.dto.settings.MetadataPersistenceSettings;
|
||||
import org.booklore.model.dto.settings.SidecarSettings;
|
||||
import org.booklore.model.dto.sidecar.SidecarMetadata;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.BookMetadataEntity;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.util.FileService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class SidecarMetadataWriter {
|
||||
|
||||
private final SidecarMetadataMapper mapper;
|
||||
private final FileService fileService;
|
||||
private final AppSettingService appSettingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SidecarMetadataWriter(SidecarMetadataMapper mapper, FileService fileService, AppSettingService appSettingService) {
|
||||
this.mapper = mapper;
|
||||
this.fileService = fileService;
|
||||
this.appSettingService = appSettingService;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
|
||||
this.objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
}
|
||||
|
||||
public void writeSidecarMetadata(BookEntity book) {
|
||||
if (book == null || book.getMetadata() == null) {
|
||||
log.warn("Cannot write sidecar metadata: book or metadata is null");
|
||||
return;
|
||||
}
|
||||
|
||||
SidecarSettings settings = getSidecarSettings();
|
||||
if (settings == null || !settings.isEnabled()) {
|
||||
log.debug("Sidecar metadata is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null || !Files.exists(bookPath)) {
|
||||
log.warn("Cannot write sidecar metadata: book file does not exist");
|
||||
return;
|
||||
}
|
||||
|
||||
Path sidecarPath = getSidecarPath(bookPath);
|
||||
BookMetadataEntity metadata = book.getMetadata();
|
||||
|
||||
String coverFileName = null;
|
||||
if (settings.isIncludeCoverFile()) {
|
||||
coverFileName = mapper.getCoverFileName(bookPath);
|
||||
writeCoverFile(book, bookPath.getParent().resolve(coverFileName));
|
||||
}
|
||||
|
||||
SidecarMetadata sidecarMetadata = mapper.toSidecarMetadata(metadata, coverFileName);
|
||||
String json = objectMapper.writeValueAsString(sidecarMetadata);
|
||||
json = json.replace(" : ", ": ").replace("[ ]", "[]");
|
||||
Files.writeString(sidecarPath, json);
|
||||
|
||||
log.info("Wrote sidecar metadata to: {}", sidecarPath);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to write sidecar metadata for book ID {}: {}", book.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteSidecarFiles(Path bookPath) {
|
||||
if (bookPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path sidecarPath = getSidecarPath(bookPath);
|
||||
if (Files.exists(sidecarPath)) {
|
||||
Files.delete(sidecarPath);
|
||||
log.info("Deleted sidecar file: {}", sidecarPath);
|
||||
}
|
||||
|
||||
Path coverPath = getCoverPath(bookPath);
|
||||
if (Files.exists(coverPath)) {
|
||||
Files.delete(coverPath);
|
||||
log.info("Deleted sidecar cover file: {}", coverPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete sidecar files for {}: {}", bookPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void moveSidecarFiles(Path oldBookPath, Path newBookPath) {
|
||||
if (oldBookPath == null || newBookPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path oldSidecarPath = getSidecarPath(oldBookPath);
|
||||
if (Files.exists(oldSidecarPath)) {
|
||||
Path newSidecarPath = getSidecarPath(newBookPath);
|
||||
Files.createDirectories(newSidecarPath.getParent());
|
||||
Files.move(oldSidecarPath, newSidecarPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("Moved sidecar file from {} to {}", oldSidecarPath, newSidecarPath);
|
||||
}
|
||||
|
||||
Path oldCoverPath = getCoverPath(oldBookPath);
|
||||
if (Files.exists(oldCoverPath)) {
|
||||
Path newCoverPath = getCoverPath(newBookPath);
|
||||
Files.move(oldCoverPath, newCoverPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("Moved sidecar cover from {} to {}", oldCoverPath, newCoverPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to move sidecar files from {} to {}: {}", oldBookPath, newBookPath, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public Path getSidecarPath(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return bookPath.getParent().resolve(baseName + ".metadata.json");
|
||||
}
|
||||
|
||||
public Path getCoverPath(Path bookPath) {
|
||||
String fileName = bookPath.getFileName().toString();
|
||||
int dotIndex = fileName.lastIndexOf('.');
|
||||
String baseName = (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName;
|
||||
return bookPath.getParent().resolve(baseName + ".cover.jpg");
|
||||
}
|
||||
|
||||
private void writeCoverFile(BookEntity book, Path coverPath) {
|
||||
try {
|
||||
String coverFile = fileService.getCoverFile(book.getId());
|
||||
Path sourceCoverPath = Path.of(coverFile);
|
||||
if (Files.exists(sourceCoverPath)) {
|
||||
Files.copy(sourceCoverPath, coverPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("Wrote cover file to: {}", coverPath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to write cover file for book ID {}: {}", book.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private SidecarSettings getSidecarSettings() {
|
||||
MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings();
|
||||
return settings != null ? settings.getSidecarSettings() : null;
|
||||
}
|
||||
|
||||
public boolean isWriteOnUpdateEnabled() {
|
||||
SidecarSettings settings = getSidecarSettings();
|
||||
return settings != null && settings.isEnabled() && settings.isWriteOnUpdate();
|
||||
}
|
||||
|
||||
public boolean isWriteOnScanEnabled() {
|
||||
SidecarSettings settings = getSidecarSettings();
|
||||
return settings != null && settings.isEnabled() && settings.isWriteOnScan();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package org.booklore.service.metadata.sidecar;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.booklore.exception.ApiError;
|
||||
import org.booklore.model.MetadataUpdateContext;
|
||||
import org.booklore.model.MetadataUpdateWrapper;
|
||||
import org.booklore.model.dto.BookMetadata;
|
||||
import org.booklore.model.dto.sidecar.SidecarMetadata;
|
||||
import org.booklore.model.entity.BookEntity;
|
||||
import org.booklore.model.entity.LibraryEntity;
|
||||
import org.booklore.model.enums.MetadataReplaceMode;
|
||||
import org.booklore.model.enums.SidecarSyncStatus;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.LibraryRepository;
|
||||
import org.booklore.service.metadata.BookMetadataUpdater;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@AllArgsConstructor
|
||||
public class SidecarService {
|
||||
|
||||
private final BookRepository bookRepository;
|
||||
private final LibraryRepository libraryRepository;
|
||||
private final SidecarMetadataReader sidecarReader;
|
||||
private final SidecarMetadataWriter sidecarWriter;
|
||||
private final SidecarMetadataMapper sidecarMapper;
|
||||
private final BookMetadataUpdater bookMetadataUpdater;
|
||||
|
||||
public Optional<SidecarMetadata> getSidecarContent(Long bookId) {
|
||||
BookEntity book = bookRepository.findByIdWithBookFiles(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return sidecarReader.readSidecarMetadata(bookPath);
|
||||
}
|
||||
|
||||
public SidecarSyncStatus getSyncStatus(Long bookId) {
|
||||
BookEntity book = bookRepository.findByIdWithBookFiles(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
return sidecarReader.getSyncStatus(book);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void exportToSidecar(Long bookId) {
|
||||
BookEntity book = bookRepository.findByIdWithBookFiles(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
sidecarWriter.writeSidecarMetadata(book);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void importFromSidecar(Long bookId) {
|
||||
BookEntity book = bookRepository.findByIdWithBookFiles(bookId)
|
||||
.orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null) {
|
||||
throw ApiError.FILE_NOT_FOUND.createException("Book has no file path");
|
||||
}
|
||||
|
||||
Optional<SidecarMetadata> sidecarOpt = sidecarReader.readSidecarMetadata(bookPath);
|
||||
if (sidecarOpt.isEmpty()) {
|
||||
throw ApiError.FILE_NOT_FOUND.createException("No sidecar file found for book");
|
||||
}
|
||||
|
||||
SidecarMetadata sidecar = sidecarOpt.get();
|
||||
BookMetadata bookMetadata = sidecarMapper.toBookMetadata(sidecar);
|
||||
|
||||
if (bookMetadata != null) {
|
||||
MetadataUpdateWrapper wrapper = MetadataUpdateWrapper.builder()
|
||||
.metadata(bookMetadata)
|
||||
.build();
|
||||
|
||||
MetadataUpdateContext context = MetadataUpdateContext.builder()
|
||||
.bookEntity(book)
|
||||
.metadataUpdateWrapper(wrapper)
|
||||
.updateThumbnail(false)
|
||||
.replaceMode(MetadataReplaceMode.REPLACE_WHEN_PROVIDED)
|
||||
.build();
|
||||
|
||||
bookMetadataUpdater.setBookMetadata(context);
|
||||
}
|
||||
|
||||
byte[] coverBytes = sidecarReader.readSidecarCover(bookPath);
|
||||
if (coverBytes != null) {
|
||||
log.info("Sidecar cover found for book ID {} - cover import is a separate operation", bookId);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int bulkExport(Long libraryId) {
|
||||
LibraryEntity library = libraryRepository.findById(libraryId)
|
||||
.orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
|
||||
|
||||
List<BookEntity> books = bookRepository.findAllByLibraryIdWithFiles(libraryId);
|
||||
int exported = 0;
|
||||
|
||||
for (BookEntity book : books) {
|
||||
try {
|
||||
sidecarWriter.writeSidecarMetadata(book);
|
||||
exported++;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to export sidecar for book ID {}: {}", book.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Bulk exported {} sidecar files for library {}", exported, library.getName());
|
||||
return exported;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int bulkImport(Long libraryId) {
|
||||
LibraryEntity library = libraryRepository.findById(libraryId)
|
||||
.orElseThrow(() -> ApiError.LIBRARY_NOT_FOUND.createException(libraryId));
|
||||
|
||||
List<BookEntity> books = bookRepository.findAllByLibraryIdWithFiles(libraryId);
|
||||
int imported = 0;
|
||||
|
||||
for (BookEntity book : books) {
|
||||
try {
|
||||
Path bookPath = book.getFullFilePath();
|
||||
if (bookPath == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Optional<SidecarMetadata> sidecarOpt = sidecarReader.readSidecarMetadata(bookPath);
|
||||
if (sidecarOpt.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SidecarMetadata sidecar = sidecarOpt.get();
|
||||
BookMetadata bookMetadata = sidecarMapper.toBookMetadata(sidecar);
|
||||
|
||||
if (bookMetadata != null) {
|
||||
MetadataUpdateWrapper wrapper = MetadataUpdateWrapper.builder()
|
||||
.metadata(bookMetadata)
|
||||
.build();
|
||||
|
||||
MetadataUpdateContext context = MetadataUpdateContext.builder()
|
||||
.bookEntity(book)
|
||||
.metadataUpdateWrapper(wrapper)
|
||||
.updateThumbnail(false)
|
||||
.replaceMode(MetadataReplaceMode.REPLACE_WHEN_PROVIDED)
|
||||
.build();
|
||||
|
||||
bookMetadataUpdater.setBookMetadata(context);
|
||||
imported++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to import sidecar for book ID {}: {}", book.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Bulk imported {} sidecar files for library {}", imported, library.getName());
|
||||
return imported;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
-- Create comic_metadata table for storing comic-specific metadata
|
||||
CREATE TABLE IF NOT EXISTS comic_metadata
|
||||
(
|
||||
book_id BIGINT PRIMARY KEY,
|
||||
@@ -27,39 +26,33 @@ CREATE TABLE IF NOT EXISTS comic_metadata
|
||||
CONSTRAINT fk_comic_metadata_book FOREIGN KEY (book_id) REFERENCES book_metadata (book_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_comic_metadata_story_arc ON comic_metadata (story_arc);
|
||||
CREATE INDEX IF NOT EXISTS idx_comic_metadata_volume_name ON comic_metadata (volume_name);
|
||||
|
||||
-- Create comic_character table
|
||||
CREATE TABLE IF NOT EXISTS comic_character
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Create comic_team table
|
||||
CREATE TABLE IF NOT EXISTS comic_team
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Create comic_location table
|
||||
CREATE TABLE IF NOT EXISTS comic_location
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Create comic_creator table
|
||||
CREATE TABLE IF NOT EXISTS comic_creator
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- Create mapping tables
|
||||
CREATE TABLE IF NOT EXISTS comic_metadata_character_mapping
|
||||
(
|
||||
book_id BIGINT NOT NULL,
|
||||
@@ -99,3 +92,39 @@ CREATE TABLE IF NOT EXISTS comic_metadata_creator_mapping
|
||||
|
||||
CREATE INDEX idx_comic_creator_mapping_role ON comic_metadata_creator_mapping (role);
|
||||
CREATE INDEX idx_comic_creator_mapping_book ON comic_metadata_creator_mapping (book_id);
|
||||
|
||||
CREATE TRIGGER trg_cleanup_orphaned_comic_character
|
||||
AFTER DELETE
|
||||
ON comic_metadata_character_mapping
|
||||
FOR EACH ROW
|
||||
DELETE
|
||||
FROM comic_character
|
||||
WHERE id = OLD.character_id
|
||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_character_mapping WHERE character_id = OLD.character_id);
|
||||
|
||||
CREATE TRIGGER trg_cleanup_orphaned_comic_team
|
||||
AFTER DELETE
|
||||
ON comic_metadata_team_mapping
|
||||
FOR EACH ROW
|
||||
DELETE
|
||||
FROM comic_team
|
||||
WHERE id = OLD.team_id
|
||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_team_mapping WHERE team_id = OLD.team_id);
|
||||
|
||||
CREATE TRIGGER trg_cleanup_orphaned_comic_location
|
||||
AFTER DELETE
|
||||
ON comic_metadata_location_mapping
|
||||
FOR EACH ROW
|
||||
DELETE
|
||||
FROM comic_location
|
||||
WHERE id = OLD.location_id
|
||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_location_mapping WHERE location_id = OLD.location_id);
|
||||
|
||||
CREATE TRIGGER trg_cleanup_orphaned_comic_creator
|
||||
AFTER DELETE
|
||||
ON comic_metadata_creator_mapping
|
||||
FOR EACH ROW
|
||||
DELETE
|
||||
FROM comic_creator
|
||||
WHERE id = OLD.creator_id
|
||||
AND NOT EXISTS (SELECT 1 FROM comic_metadata_creator_mapping WHERE creator_id = OLD.creator_id);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE library
|
||||
ADD COLUMN metadata_source VARCHAR(20) DEFAULT 'EMBEDDED';
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE library_path
|
||||
DROP FOREIGN KEY fk_library_path;
|
||||
ALTER TABLE library_path
|
||||
ADD CONSTRAINT fk_library_path FOREIGN KEY (library_id) REFERENCES library (id) ON DELETE CASCADE;
|
||||
@@ -8,6 +8,7 @@ import org.booklore.service.book.BookQueryService;
|
||||
import org.booklore.service.book.BookService;
|
||||
import org.booklore.service.book.BookUpdateService;
|
||||
import org.booklore.service.progress.ReadingProgressService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.booklore.util.FileService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -51,6 +52,7 @@ class BookServiceDeleteTests {
|
||||
BookDownloadService bookDownloadService = Mockito.mock(BookDownloadService.class);
|
||||
MonitoringRegistrationService monitoringRegistrationService = Mockito.mock(MonitoringRegistrationService.class);
|
||||
BookUpdateService bookUpdateService = Mockito.mock(BookUpdateService.class);
|
||||
SidecarMetadataWriter sidecarMetadataWriter = Mockito.mock(SidecarMetadataWriter.class);
|
||||
|
||||
bookService = new BookService(
|
||||
bookRepository,
|
||||
@@ -67,7 +69,8 @@ class BookServiceDeleteTests {
|
||||
bookDownloadService,
|
||||
monitoringRegistrationService,
|
||||
bookUpdateService,
|
||||
ebookViewerPreferenceRepository
|
||||
ebookViewerPreferenceRepository,
|
||||
sidecarMetadataWriter
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.booklore.repository.BookAdditionalFileRepository;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.LibraryRepository;
|
||||
import org.booklore.service.NotificationService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -57,6 +58,7 @@ class FileMoveServiceOrderingTest {
|
||||
|
||||
@Mock private EntityManager entityManager;
|
||||
@Mock private TransactionTemplate transactionTemplate;
|
||||
@Mock private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
private FileMoveService service;
|
||||
private LibraryEntity library;
|
||||
@@ -67,9 +69,9 @@ class FileMoveServiceOrderingTest {
|
||||
LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper,
|
||||
MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper,
|
||||
BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
TransactionTemplate transactionTemplate, SidecarMetadataWriter sidecarMetadataWriter) {
|
||||
super(appProperties, bookRepository, bookFileRepository, libraryRepository, fileMoveHelper,
|
||||
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate);
|
||||
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate, sidecarMetadataWriter);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -89,7 +91,7 @@ class FileMoveServiceOrderingTest {
|
||||
}).when(transactionTemplate).executeWithoutResult(any());
|
||||
|
||||
service = spy(new TestableFileMoveService(appProperties, bookRepository, bookFileRepository, libraryRepository,
|
||||
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate));
|
||||
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate, sidecarMetadataWriter));
|
||||
|
||||
library = new LibraryEntity();
|
||||
library.setId(1L);
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.booklore.repository.BookAdditionalFileRepository;
|
||||
import org.booklore.repository.BookRepository;
|
||||
import org.booklore.repository.LibraryRepository;
|
||||
import org.booklore.service.NotificationService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.monitoring.MonitoringRegistrationService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -52,6 +53,7 @@ class FileMoveServiceTest {
|
||||
@Mock private NotificationService notificationService;
|
||||
@Mock private EntityManager entityManager;
|
||||
@Mock private org.springframework.transaction.support.TransactionTemplate transactionTemplate;
|
||||
@Mock private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
private FileMoveService service;
|
||||
private LibraryEntity library;
|
||||
@@ -62,9 +64,10 @@ class FileMoveServiceTest {
|
||||
LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper,
|
||||
MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper,
|
||||
BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager,
|
||||
org.springframework.transaction.support.TransactionTemplate transactionTemplate) {
|
||||
org.springframework.transaction.support.TransactionTemplate transactionTemplate,
|
||||
SidecarMetadataWriter sidecarMetadataWriter) {
|
||||
super(appProperties, bookRepository, bookFileRepository, libraryRepository, fileMoveHelper,
|
||||
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate);
|
||||
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate, sidecarMetadataWriter);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -85,7 +88,7 @@ class FileMoveServiceTest {
|
||||
}).when(transactionTemplate).executeWithoutResult(any());
|
||||
|
||||
service = spy(new TestableFileMoveService(appProperties, bookRepository, bookFileRepository, libraryRepository,
|
||||
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate));
|
||||
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate, sidecarMetadataWriter));
|
||||
|
||||
library = new LibraryEntity();
|
||||
library.setId(1L);
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.booklore.repository.BookRepository;
|
||||
import org.booklore.service.book.BookCreatorService;
|
||||
import org.booklore.service.metadata.MetadataMatchService;
|
||||
import org.booklore.service.metadata.extractor.CbxMetadataExtractor;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.util.FileService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -41,6 +42,7 @@ class CbxProcessorTest {
|
||||
@Mock private BookMapper bookMapper;
|
||||
@Mock private FileService fileService;
|
||||
@Mock private MetadataMatchService metadataMatchService;
|
||||
@Mock private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
@Mock private CbxMetadataExtractor cbxMetadataExtractor;
|
||||
|
||||
private CbxProcessor cbxProcessor;
|
||||
@@ -57,6 +59,7 @@ class CbxProcessorTest {
|
||||
bookMapper,
|
||||
fileService,
|
||||
metadataMatchService,
|
||||
sidecarMetadataWriter,
|
||||
cbxMetadataExtractor
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.MetadataReplaceMode;
|
||||
import org.booklore.repository.*;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.file.FileMoveService;
|
||||
import org.booklore.service.metadata.writer.MetadataWriterFactory;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -47,6 +48,16 @@ class BookMetadataUpdaterCategoryTest {
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
@Mock
|
||||
private ComicMetadataRepository comicMetadataRepository;
|
||||
@Mock
|
||||
private ComicCharacterRepository comicCharacterRepository;
|
||||
@Mock
|
||||
private ComicTeamRepository comicTeamRepository;
|
||||
@Mock
|
||||
private ComicLocationRepository comicLocationRepository;
|
||||
@Mock
|
||||
private ComicCreatorRepository comicCreatorRepository;
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
@Mock
|
||||
private MetadataMatchService metadataMatchService;
|
||||
@@ -58,6 +69,8 @@ class BookMetadataUpdaterCategoryTest {
|
||||
private BookReviewUpdateService bookReviewUpdateService;
|
||||
@Mock
|
||||
private FileMoveService fileMoveService;
|
||||
@Mock
|
||||
private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
@InjectMocks
|
||||
private BookMetadataUpdater bookMetadataUpdater;
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.booklore.model.enums.BookFileType;
|
||||
import org.booklore.model.enums.MetadataReplaceMode;
|
||||
import org.booklore.repository.*;
|
||||
import org.booklore.service.appsettings.AppSettingService;
|
||||
import org.booklore.service.metadata.sidecar.SidecarMetadataWriter;
|
||||
import org.booklore.service.file.FileMoveService;
|
||||
import org.booklore.service.metadata.writer.MetadataWriterFactory;
|
||||
import org.booklore.util.FileService;
|
||||
@@ -40,6 +41,16 @@ class BookMetadataUpdaterTest {
|
||||
@Mock
|
||||
private BookRepository bookRepository;
|
||||
@Mock
|
||||
private ComicMetadataRepository comicMetadataRepository;
|
||||
@Mock
|
||||
private ComicCharacterRepository comicCharacterRepository;
|
||||
@Mock
|
||||
private ComicTeamRepository comicTeamRepository;
|
||||
@Mock
|
||||
private ComicLocationRepository comicLocationRepository;
|
||||
@Mock
|
||||
private ComicCreatorRepository comicCreatorRepository;
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
@Mock
|
||||
private MetadataMatchService metadataMatchService;
|
||||
@@ -51,6 +62,8 @@ class BookMetadataUpdaterTest {
|
||||
private BookReviewUpdateService bookReviewUpdateService;
|
||||
@Mock
|
||||
private FileMoveService fileMoveService;
|
||||
@Mock
|
||||
private SidecarMetadataWriter sidecarMetadataWriter;
|
||||
|
||||
@InjectMocks
|
||||
private BookMetadataUpdater bookMetadataUpdater;
|
||||
|
||||
Reference in New Issue
Block a user