mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-01-09 06:21:08 +08:00
Feat: Enhancements to Kobo Metadata sync (#1664)
* feat: improve kobo metadata change syncing * fix: resolve issue with kobo thumbnail passthrough
This commit is contained in:
parent
5abed1ad53
commit
80dcc45213
@ -71,7 +71,18 @@ public class KoboController {
|
||||
return koboLibrarySyncService.syncLibrary(user, token);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a book.")
|
||||
@Operation(summary = "Get book thumbnail (versioned)", description = "Retrieve the thumbnail image for a local book with cache-busting version.")
|
||||
@ApiResponse(responseCode = "200", description = "Thumbnail returned successfully")
|
||||
@GetMapping("/v1/books/{bookId}/{version}/thumbnail/{width}/{height}/false/image.jpg")
|
||||
public ResponseEntity<Resource> getVersionedThumbnail(
|
||||
@Parameter(description = "Book ID") @PathVariable Long bookId,
|
||||
@Parameter(description = "Cover version (timestamp)") @PathVariable String version,
|
||||
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
|
||||
@Parameter(description = "Height of the thumbnail") @PathVariable int height) {
|
||||
return koboThumbnailService.getThumbnail(bookId);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a Kobo store book.")
|
||||
@ApiResponse(responseCode = "200", description = "Thumbnail returned successfully")
|
||||
@GetMapping("/v1/books/{imageId}/thumbnail/{width}/{height}/false/image.jpg")
|
||||
public ResponseEntity<Resource> getThumbnail(
|
||||
@ -82,25 +93,38 @@ public class KoboController {
|
||||
if (StringUtils.isNumeric(imageId)) {
|
||||
return koboThumbnailService.getThumbnail(Long.valueOf(imageId));
|
||||
} else {
|
||||
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/image.jpg", imageId, width, height);
|
||||
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/false/image.jpg", imageId, width, height);
|
||||
return koboServerProxy.proxyExternalUrl(cdnUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "Get greyscale book thumbnail", description = "Retrieve a greyscale thumbnail image for a book.")
|
||||
@Operation(summary = "Get greyscale book thumbnail (versioned)", description = "Retrieve a greyscale thumbnail for a local book with cache-busting version.")
|
||||
@ApiResponse(responseCode = "200", description = "Greyscale thumbnail returned successfully")
|
||||
@GetMapping("/v1/books/{bookId}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
|
||||
@GetMapping("/v1/books/{bookId}/{version}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
|
||||
public ResponseEntity<Resource> getVersionedGreyThumbnail(
|
||||
@Parameter(description = "Book ID") @PathVariable Long bookId,
|
||||
@Parameter(description = "Cover version (timestamp)") @PathVariable String version,
|
||||
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
|
||||
@Parameter(description = "Height of the thumbnail") @PathVariable int height,
|
||||
@Parameter(description = "Quality of the thumbnail") @PathVariable int quality,
|
||||
@Parameter(description = "Is greyscale") @PathVariable boolean isGreyscale) {
|
||||
return koboThumbnailService.getThumbnail(bookId);
|
||||
}
|
||||
|
||||
@Operation(summary = "Get greyscale book thumbnail", description = "Retrieve a greyscale thumbnail image for a Kobo store book.")
|
||||
@ApiResponse(responseCode = "200", description = "Greyscale thumbnail returned successfully")
|
||||
@GetMapping("/v1/books/{imageId}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
|
||||
public ResponseEntity<Resource> getGreyThumbnail(
|
||||
@Parameter(description = "Book ID") @PathVariable String bookId,
|
||||
@Parameter(description = "Image ID") @PathVariable String imageId,
|
||||
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
|
||||
@Parameter(description = "Height of the thumbnail") @PathVariable int height,
|
||||
@Parameter(description = "Quality of the thumbnail") @PathVariable int quality,
|
||||
@Parameter(description = "Is greyscale") @PathVariable boolean isGreyscale) {
|
||||
|
||||
if (StringUtils.isNumeric(bookId)) {
|
||||
return koboThumbnailService.getThumbnail(Long.valueOf(bookId));
|
||||
if (StringUtils.isNumeric(imageId)) {
|
||||
return koboThumbnailService.getThumbnail(Long.valueOf(imageId));
|
||||
} else {
|
||||
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/%d/%b/image.jpg", bookId, width, height, quality, isGreyscale);
|
||||
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/%d/%b/image.jpg", imageId, width, height, quality, isGreyscale);
|
||||
return koboServerProxy.proxyExternalUrl(cdnUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,8 @@ public class BookEntitlement {
|
||||
private ActivePeriod activePeriod;
|
||||
|
||||
@JsonProperty("IsRemoved")
|
||||
private boolean isRemoved;
|
||||
@Builder.Default
|
||||
private Boolean removed = false;
|
||||
|
||||
private String status;
|
||||
|
||||
@ -31,7 +32,7 @@ public class BookEntitlement {
|
||||
|
||||
@JsonProperty("IsHiddenFromArchive")
|
||||
@Builder.Default
|
||||
private boolean isHiddenFromArchive = false;
|
||||
private Boolean hiddenFromArchive = false;
|
||||
|
||||
private String id;
|
||||
private String created;
|
||||
@ -39,7 +40,7 @@ public class BookEntitlement {
|
||||
|
||||
@JsonProperty("IsLocked")
|
||||
@Builder.Default
|
||||
private boolean isLocked = false;
|
||||
private Boolean locked = false;
|
||||
|
||||
@Builder.Default
|
||||
private String originCategory = "Imported";
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
package com.adityachandel.booklore.model.dto.kobo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ChangedProductMetadata implements Entitlement {
|
||||
private BookEntitlementContainer changedProductMetadata;
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.adityachandel.booklore.model.dto.kobo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonNaming;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ChangedReadingState implements Entitlement {
|
||||
@JsonProperty("ChangedReadingState")
|
||||
private ReadingStateWrapper readingState;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public static class ReadingStateWrapper {
|
||||
private KoboReadingState readingState;
|
||||
}
|
||||
}
|
||||
@ -29,13 +29,13 @@ public class KoboBookMetadata {
|
||||
private String language = "en";
|
||||
|
||||
private String isbn;
|
||||
private String genre;
|
||||
private String genre = "00000000-0000-0000-0000-000000000001";
|
||||
private String slug;
|
||||
private String coverImageId;
|
||||
|
||||
@JsonProperty("IsSocialEnabled")
|
||||
@Builder.Default
|
||||
private boolean isSocialEnabled = false;
|
||||
private boolean socialEnabled = true;
|
||||
|
||||
private String workId;
|
||||
|
||||
@ -44,14 +44,14 @@ public class KoboBookMetadata {
|
||||
|
||||
@JsonProperty("IsPreOrder")
|
||||
@Builder.Default
|
||||
private boolean isPreOrder = false;
|
||||
private boolean preOrder = false;
|
||||
|
||||
@Builder.Default
|
||||
private List<ContributorRole> contributorRoles = new ArrayList<>();
|
||||
|
||||
@JsonProperty("IsInternetArchive")
|
||||
@Builder.Default
|
||||
private boolean isInternetArchive = false;
|
||||
private boolean internetArchive = false;
|
||||
|
||||
private String entitlementId;
|
||||
private String title;
|
||||
@ -81,7 +81,7 @@ public class KoboBookMetadata {
|
||||
|
||||
@JsonProperty("IsEligibleForKoboLove")
|
||||
@Builder.Default
|
||||
private boolean isEligibleForKoboLove = false;
|
||||
private boolean eligibleForKoboLove = false;
|
||||
|
||||
@Builder.Default
|
||||
private Map<String, String> phoneticPronunciations = Map.of();
|
||||
|
||||
@ -44,6 +44,12 @@ public class BookEntity {
|
||||
@OneToOne(mappedBy = "book", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
private BookMetadataEntity metadata;
|
||||
|
||||
@Column(name = "metadata_updated_at")
|
||||
private Instant metadataUpdatedAt;
|
||||
|
||||
@Column(name = "metadata_for_write_updated_at")
|
||||
private Instant metadataForWriteUpdatedAt;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "library_id", nullable = false)
|
||||
private LibraryEntity library;
|
||||
|
||||
@ -4,6 +4,8 @@ package com.adityachandel.booklore.model.entity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "kobo_library_snapshot_book")
|
||||
@Getter
|
||||
@ -24,6 +26,12 @@ public class KoboSnapshotBookEntity {
|
||||
@Column(name = "book_id", nullable = false)
|
||||
private Long bookId;
|
||||
|
||||
@Column(name = "file_hash")
|
||||
private String fileHash;
|
||||
|
||||
@Column(name = "metadata_updated_at")
|
||||
private Instant metadataUpdatedAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
private boolean synced = false;
|
||||
|
||||
@ -36,6 +36,21 @@ public interface KoboSnapshotBookRepository extends JpaRepository<KoboSnapshotBo
|
||||
@Param("currSnapshotId") String currSnapshotId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
SELECT curr
|
||||
FROM KoboSnapshotBookEntity curr
|
||||
JOIN KoboSnapshotBookEntity prev
|
||||
ON curr.bookId = prev.bookId
|
||||
WHERE curr.snapshot.id = :currSnapshotId
|
||||
AND prev.snapshot.id = :prevSnapshotId
|
||||
AND curr.fileHash = prev.fileHash
|
||||
AND (curr.metadataUpdatedAt = prev.metadataUpdatedAt OR (curr.metadataUpdatedAt IS NULL AND prev.metadataUpdatedAt IS NULL))
|
||||
""")
|
||||
List<KoboSnapshotBookEntity> findUnchangedBooksBetweenSnapshots(
|
||||
@Param("prevSnapshotId") String prevSnapshotId,
|
||||
@Param("currSnapshotId") String currSnapshotId
|
||||
);
|
||||
|
||||
@Query("""
|
||||
SELECT curr
|
||||
FROM KoboSnapshotBookEntity curr
|
||||
@ -74,4 +89,24 @@ public interface KoboSnapshotBookRepository extends JpaRepository<KoboSnapshotBo
|
||||
@Param("currSnapshotId") String currSnapshotId,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
@Query("""
|
||||
SELECT curr
|
||||
FROM KoboSnapshotBookEntity curr
|
||||
JOIN KoboSnapshotBookEntity prev
|
||||
ON curr.bookId = prev.bookId
|
||||
WHERE curr.snapshot.id = :currSnapshotId
|
||||
AND prev.snapshot.id = :prevSnapshotId
|
||||
AND curr.synced = false
|
||||
AND (
|
||||
curr.fileHash <> prev.fileHash
|
||||
OR (curr.metadataUpdatedAt <> prev.metadataUpdatedAt AND curr.metadataUpdatedAt IS NOT NULL AND prev.metadataUpdatedAt IS NOT NULL)
|
||||
OR (curr.metadataUpdatedAt IS NOT NULL AND prev.metadataUpdatedAt IS NULL)
|
||||
)
|
||||
""")
|
||||
Page<KoboSnapshotBookEntity> findChangedBooks(
|
||||
@Param("prevSnapshotId") String prevSnapshotId,
|
||||
@Param("currSnapshotId") String currSnapshotId,
|
||||
Pageable pageable
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,11 +33,6 @@ public class BookQueryService {
|
||||
return bookRepository.findAllWithMetadataByIds(bookIds);
|
||||
}
|
||||
|
||||
public List<BookEntity> findWithMetadataByIdsWithPagination(Set<Long> bookIds, int offset, int limit) {
|
||||
Pageable pageable = PageRequest.of(offset / limit, limit);
|
||||
return bookRepository.findWithMetadataByIdsWithPagination(bookIds, pageable);
|
||||
}
|
||||
|
||||
public List<BookEntity> getAllFullBookEntities() {
|
||||
return bookRepository.findAllFullBooks();
|
||||
}
|
||||
|
||||
@ -2,14 +2,11 @@ package com.adityachandel.booklore.service.kobo;
|
||||
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.mapper.KoboReadingStateMapper;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.kobo.*;
|
||||
import com.adityachandel.booklore.model.dto.settings.KoboSettings;
|
||||
import com.adityachandel.booklore.model.entity.AuthorEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookMetadataEntity;
|
||||
import com.adityachandel.booklore.model.entity.CategoryEntity;
|
||||
import com.adityachandel.booklore.model.entity.UserBookProgressEntity;
|
||||
import com.adityachandel.booklore.model.enums.BookFileType;
|
||||
import com.adityachandel.booklore.model.enums.KoboBookFormat;
|
||||
import com.adityachandel.booklore.model.enums.KoboReadStatus;
|
||||
@ -27,14 +24,11 @@ import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@AllArgsConstructor
|
||||
@Service
|
||||
public class KoboEntitlementService {
|
||||
|
||||
private static final Pattern NON_ALPHANUMERIC_LOWERCASE_PATTERN = Pattern.compile("[^a-z0-9]");
|
||||
private final KoboUrlBuilder koboUrlBuilder;
|
||||
private final BookQueryService bookQueryService;
|
||||
private final AppSettingService appSettingService;
|
||||
@ -44,29 +38,29 @@ public class KoboEntitlementService {
|
||||
private final AuthenticationService authenticationService;
|
||||
private final KoboReadingStateBuilder readingStateBuilder;
|
||||
|
||||
public List<NewEntitlement> generateNewEntitlements(Set<Long> bookIds, String token, boolean removed) {
|
||||
public List<NewEntitlement> generateNewEntitlements(Set<Long> bookIds, String token) {
|
||||
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
|
||||
|
||||
return books.stream()
|
||||
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
|
||||
.map(book -> NewEntitlement.builder()
|
||||
.newEntitlement(BookEntitlementContainer.builder()
|
||||
.bookEntitlement(buildBookEntitlement(book, removed))
|
||||
.bookEntitlement(buildBookEntitlement(book, false))
|
||||
.bookMetadata(mapToKoboMetadata(book, token))
|
||||
.readingState(createInitialReadingState(book))
|
||||
.readingState(getReadingStateForBook(book))
|
||||
.build())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
.collect(Collectors.<NewEntitlement>toList());
|
||||
}
|
||||
|
||||
public List<ChangedEntitlement> generateChangedEntitlements(Set<Long> bookIds, String token, boolean removed) {
|
||||
public List<? extends Entitlement> generateChangedEntitlements(Set<Long> bookIds, String token, boolean removed) {
|
||||
List<BookEntity> books = bookQueryService.findAllWithMetadataByIds(bookIds);
|
||||
return books.stream()
|
||||
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
|
||||
.map(book -> {
|
||||
KoboBookMetadata metadata;
|
||||
if (removed) {
|
||||
metadata = KoboBookMetadata.builder()
|
||||
|
||||
if (removed) {
|
||||
return books.stream()
|
||||
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
|
||||
.map(book -> {
|
||||
KoboBookMetadata metadata = KoboBookMetadata.builder()
|
||||
.coverImageId(String.valueOf(book.getId()))
|
||||
.crossRevisionId(String.valueOf(book.getId()))
|
||||
.entitlementId(String.valueOf(book.getId()))
|
||||
@ -74,48 +68,69 @@ public class KoboEntitlementService {
|
||||
.workId(String.valueOf(book.getId()))
|
||||
.title(String.valueOf(book.getId()))
|
||||
.build();
|
||||
} else {
|
||||
metadata = mapToKoboMetadata(book, token);
|
||||
}
|
||||
return ChangedEntitlement.builder()
|
||||
.changedEntitlement(BookEntitlementContainer.builder()
|
||||
.bookEntitlement(buildBookEntitlement(book, true))
|
||||
.bookMetadata(metadata)
|
||||
.build())
|
||||
.build();
|
||||
})
|
||||
|
||||
return ChangedEntitlement.builder()
|
||||
.changedEntitlement(BookEntitlementContainer.builder()
|
||||
.bookEntitlement(buildBookEntitlement(book, removed))
|
||||
.bookMetadata(metadata)
|
||||
.build())
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
return books.stream()
|
||||
.filter(bookEntity -> bookEntity.getBookType() == BookFileType.EPUB)
|
||||
.map(book -> ChangedProductMetadata.builder()
|
||||
.changedProductMetadata(BookEntitlementContainer.builder()
|
||||
.bookEntitlement(buildBookEntitlement(book, false))
|
||||
.bookMetadata(mapToKoboMetadata(book, token))
|
||||
.build())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private KoboReadingState createInitialReadingState(BookEntity book) {
|
||||
private KoboReadingState getReadingStateForBook(BookEntity book) {
|
||||
OffsetDateTime now = getCurrentUtc();
|
||||
OffsetDateTime createdOn = getCreatedOn(book);
|
||||
String entitlementId = String.valueOf(book.getId());
|
||||
|
||||
|
||||
KoboReadingState existingState = readingStateRepository.findByEntitlementId(entitlementId)
|
||||
.map(readingStateMapper::toDto)
|
||||
.orElse(null);
|
||||
|
||||
|
||||
KoboReadingState.CurrentBookmark bookmark;
|
||||
if (existingState != null && existingState.getCurrentBookmark() != null) {
|
||||
bookmark = existingState.getCurrentBookmark();
|
||||
KoboReadingState.StatusInfo statusInfo;
|
||||
|
||||
if (existingState != null) {
|
||||
bookmark = existingState.getCurrentBookmark() != null
|
||||
? existingState.getCurrentBookmark()
|
||||
: readingStateBuilder.buildEmptyBookmark(now);
|
||||
statusInfo = existingState.getStatusInfo() != null
|
||||
? existingState.getStatusInfo()
|
||||
: KoboReadingState.StatusInfo.builder()
|
||||
.lastModified(now.toString())
|
||||
.status(KoboReadStatus.READY_TO_READ)
|
||||
.timesStartedReading(0)
|
||||
.build();
|
||||
} else {
|
||||
bookmark = progressRepository
|
||||
.findByUserIdAndBookId(authenticationService.getAuthenticatedUser().getId(), book.getId())
|
||||
.filter(progress -> progress.getKoboProgressPercent() != null)
|
||||
.map(progress -> readingStateBuilder.buildBookmarkFromProgress(progress, now))
|
||||
.orElseGet(() -> readingStateBuilder.buildEmptyBookmark(now));
|
||||
statusInfo = KoboReadingState.StatusInfo.builder()
|
||||
.lastModified(now.toString())
|
||||
.status(KoboReadStatus.READY_TO_READ)
|
||||
.timesStartedReading(0)
|
||||
.build();
|
||||
}
|
||||
|
||||
return KoboReadingState.builder()
|
||||
.entitlementId(entitlementId)
|
||||
.created(createdOn.toString())
|
||||
.lastModified(now.toString())
|
||||
.statusInfo(KoboReadingState.StatusInfo.builder()
|
||||
.lastModified(now.toString())
|
||||
.status(KoboReadStatus.READY_TO_READ)
|
||||
.timesStartedReading(0)
|
||||
.build())
|
||||
.statusInfo(statusInfo)
|
||||
.currentBookmark(bookmark)
|
||||
.statistics(KoboReadingState.Statistics.builder()
|
||||
.lastModified(now.toString())
|
||||
@ -132,7 +147,7 @@ public class KoboEntitlementService {
|
||||
.activePeriod(BookEntitlement.ActivePeriod.builder()
|
||||
.from(now.toString())
|
||||
.build())
|
||||
.isRemoved(removed)
|
||||
.removed(removed)
|
||||
.status("Active")
|
||||
.crossRevisionId(String.valueOf(book.getId()))
|
||||
.revisionId(String.valueOf(book.getId()))
|
||||
@ -162,10 +177,6 @@ public class KoboEntitlementService {
|
||||
.map(list -> list.stream().map(AuthorEntity::getName).toList())
|
||||
.orElse(Collections.emptyList());
|
||||
|
||||
List<String> categories = Optional.ofNullable(metadata.getCategories())
|
||||
.map(list -> list.stream().map(CategoryEntity::getName).toList())
|
||||
.orElse(Collections.emptyList());
|
||||
|
||||
KoboBookMetadata.Series series = null;
|
||||
if (metadata.getSeriesName() != null) {
|
||||
series = KoboBookMetadata.Series.builder()
|
||||
@ -184,6 +195,11 @@ public class KoboEntitlementService {
|
||||
bookFormat = KoboBookFormat.KEPUB;
|
||||
}
|
||||
|
||||
String coverVersion = metadata.getCoverUpdatedOn() != null
|
||||
? String.valueOf(metadata.getCoverUpdatedOn().getEpochSecond())
|
||||
: "0";
|
||||
String coverImageId = book.getId() + "/" + coverVersion;
|
||||
|
||||
return KoboBookMetadata.builder()
|
||||
.crossRevisionId(String.valueOf(book.getId()))
|
||||
.revisionId(String.valueOf(book.getId()))
|
||||
@ -192,14 +208,18 @@ public class KoboEntitlementService {
|
||||
? metadata.getPublishedDate().atStartOfDay().atOffset(ZoneOffset.UTC).toString()
|
||||
: null)
|
||||
.isbn(metadata.getIsbn13() != null ? metadata.getIsbn13() : metadata.getIsbn10())
|
||||
.genre(categories.isEmpty() ? null : categories.getFirst())
|
||||
.slug(metadata.getTitle() != null
|
||||
.genre("00000000-0000-0000-0000-000000000001")
|
||||
/*.slug(metadata.getTitle() != null
|
||||
? NON_ALPHANUMERIC_LOWERCASE_PATTERN.matcher(metadata.getTitle().toLowerCase()).replaceAll("-")
|
||||
: null)
|
||||
.coverImageId(String.valueOf(metadata.getBookId()))
|
||||
: null)*/
|
||||
.coverImageId(coverImageId)
|
||||
.workId(String.valueOf(book.getId()))
|
||||
.isPreOrder(false)
|
||||
.contributorRoles(Collections.emptyList())
|
||||
.preOrder(false)
|
||||
.contributorRoles(metadata.getAuthors().stream()
|
||||
.map(author -> KoboBookMetadata.ContributorRole.builder()
|
||||
.name(author.getName())
|
||||
.build())
|
||||
.collect(Collectors.toList()))
|
||||
.entitlementId(String.valueOf(book.getId()))
|
||||
.title(metadata.getTitle())
|
||||
.description(metadata.getDescription())
|
||||
|
||||
@ -62,13 +62,13 @@ public class KoboLibrarySnapshotService {
|
||||
|
||||
@Transactional
|
||||
public void updateSyncedStatusForExistingBooks(String previousSnapshotId, String currentSnapshotId) {
|
||||
List<KoboSnapshotBookEntity> list = koboSnapshotBookRepository.findExistingBooksBetweenSnapshots(previousSnapshotId, currentSnapshotId);
|
||||
List<Long> existingBooks = list.stream()
|
||||
List<KoboSnapshotBookEntity> list = koboSnapshotBookRepository.findUnchangedBooksBetweenSnapshots(previousSnapshotId, currentSnapshotId);
|
||||
List<Long> unchangedBooks = list.stream()
|
||||
.map(KoboSnapshotBookEntity::getBookId)
|
||||
.toList();
|
||||
|
||||
if (!existingBooks.isEmpty()) {
|
||||
koboSnapshotBookRepository.markBooksSynced(currentSnapshotId, existingBooks);
|
||||
if (!unchangedBooks.isEmpty()) {
|
||||
koboSnapshotBookRepository.markBooksSynced(currentSnapshotId, unchangedBooks);
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,6 +108,20 @@ public class KoboLibrarySnapshotService {
|
||||
return page;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Page<KoboSnapshotBookEntity> getChangedBooks(String previousSnapshotId, String currentSnapshotId, Pageable pageable) {
|
||||
Page<KoboSnapshotBookEntity> page = koboSnapshotBookRepository.findChangedBooks(previousSnapshotId, currentSnapshotId, pageable);
|
||||
List<Long> changedBookIds = page.getContent().stream()
|
||||
.map(KoboSnapshotBookEntity::getBookId)
|
||||
.toList();
|
||||
|
||||
if (!changedBookIds.isEmpty()) {
|
||||
koboSnapshotBookRepository.markBooksSynced(currentSnapshotId, changedBookIds);
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
private ShelfEntity getKoboShelf(Long userId) {
|
||||
return shelfRepository
|
||||
.findByUserIdAndName(userId, ShelfType.KOBO.getName())
|
||||
@ -122,6 +136,8 @@ public class KoboLibrarySnapshotService {
|
||||
.map(book -> {
|
||||
KoboSnapshotBookEntity snapshotBook = mapper.toKoboSnapshotBook(book);
|
||||
snapshotBook.setSnapshot(snapshot);
|
||||
snapshotBook.setFileHash(book.getCurrentHash());
|
||||
snapshotBook.setMetadataUpdatedAt(book.getMetadataUpdatedAt());
|
||||
return snapshotBook;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@ -48,6 +48,7 @@ public class KoboLibrarySyncService {
|
||||
if (prevSnapshot.isPresent()) {
|
||||
int maxRemaining = 5;
|
||||
List<KoboSnapshotBookEntity> removedAll = new ArrayList<>();
|
||||
List<KoboSnapshotBookEntity> changedAll = new ArrayList<>();
|
||||
|
||||
koboLibrarySnapshotService.updateSyncedStatusForExistingBooks(prevSnapshot.get().getId(), currSnapshot.getId());
|
||||
|
||||
@ -56,30 +57,40 @@ public class KoboLibrarySyncService {
|
||||
maxRemaining -= addedPage.getNumberOfElements();
|
||||
shouldContinueSync = addedPage.hasNext();
|
||||
|
||||
Page<KoboSnapshotBookEntity> removedPage = Page.empty();
|
||||
Page<KoboSnapshotBookEntity> changedPage = Page.empty();
|
||||
if (addedPage.isLast() && maxRemaining > 0) {
|
||||
changedPage = koboLibrarySnapshotService.getChangedBooks(prevSnapshot.get().getId(), currSnapshot.getId(), PageRequest.of(0, maxRemaining));
|
||||
changedAll.addAll(changedPage.getContent());
|
||||
maxRemaining -= changedPage.getNumberOfElements();
|
||||
shouldContinueSync = shouldContinueSync || changedPage.hasNext();
|
||||
}
|
||||
|
||||
Page<KoboSnapshotBookEntity> removedPage = Page.empty();
|
||||
if (changedPage.isLast() && maxRemaining > 0) {
|
||||
removedPage = koboLibrarySnapshotService.getRemovedBooks(prevSnapshot.get().getId(), currSnapshot.getId(), user.getId(), PageRequest.of(0, maxRemaining));
|
||||
removedAll.addAll(removedPage.getContent());
|
||||
shouldContinueSync = shouldContinueSync || removedPage.hasNext();
|
||||
}
|
||||
|
||||
Set<Long> addedIds = addedAll.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet());
|
||||
Set<Long> changedIds = changedAll.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet());
|
||||
Set<Long> removedIds = removedAll.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet());
|
||||
|
||||
entitlements.addAll(entitlementService.generateNewEntitlements(addedIds, token, false));
|
||||
entitlements.addAll(entitlementService.generateNewEntitlements(addedIds, token));
|
||||
entitlements.addAll(entitlementService.generateChangedEntitlements(changedIds, token, false));
|
||||
entitlements.addAll(entitlementService.generateChangedEntitlements(removedIds, token, true));
|
||||
} else {
|
||||
int maxRemaining = 5;
|
||||
List<KoboSnapshotBookEntity> all = new ArrayList<>();
|
||||
List<KoboSnapshotBookEntity> snapshotBookEntities = new ArrayList<>();
|
||||
while (maxRemaining > 0) {
|
||||
var page = koboLibrarySnapshotService.getUnsyncedBooks(currSnapshot.getId(), PageRequest.of(0, maxRemaining));
|
||||
all.addAll(page.getContent());
|
||||
Page<KoboSnapshotBookEntity> page = koboLibrarySnapshotService.getUnsyncedBooks(currSnapshot.getId(), PageRequest.of(0, maxRemaining));
|
||||
snapshotBookEntities.addAll(page.getContent());
|
||||
maxRemaining -= page.getNumberOfElements();
|
||||
shouldContinueSync = page.hasNext();
|
||||
if (!shouldContinueSync || page.getNumberOfElements() == 0) break;
|
||||
}
|
||||
Set<Long> ids = all.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet());
|
||||
entitlements.addAll(entitlementService.generateNewEntitlements(ids, token, false));
|
||||
Set<Long> ids = snapshotBookEntities.stream().map(KoboSnapshotBookEntity::getBookId).collect(Collectors.toSet());
|
||||
entitlements.addAll(entitlementService.generateNewEntitlements(ids, token));
|
||||
}
|
||||
|
||||
if (!shouldContinueSync) {
|
||||
|
||||
@ -16,8 +16,6 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
@ -26,7 +24,6 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
@ -95,15 +92,13 @@ public class KoboServerProxy {
|
||||
String koboBaseUrl = "https://storeapi.kobo.com";
|
||||
|
||||
String queryString = request.getQueryString();
|
||||
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(koboBaseUrl)
|
||||
.path(path);
|
||||
|
||||
String fullUrl = koboBaseUrl + path;
|
||||
if (queryString != null && !queryString.isBlank()) {
|
||||
uriBuilder.query(queryString);
|
||||
fullUrl += "?" + queryString;
|
||||
}
|
||||
|
||||
URI uri = uriBuilder.build(true).toUri();
|
||||
log.info("Kobo proxy URL: {}", uri);
|
||||
URI uri = URI.create(fullUrl);
|
||||
log.debug("Kobo proxy URL: {}", uri);
|
||||
|
||||
String bodyString = body != null ? objectMapper.writeValueAsString(body) : "{}";
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
@ -150,7 +145,7 @@ public class KoboServerProxy {
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Kobo proxy response status: {}", response.statusCode());
|
||||
log.debug("Kobo proxy response status: {}", response.statusCode());
|
||||
|
||||
return new ResponseEntity<>(responseBody, responseHeaders, HttpStatus.valueOf(response.statusCode()));
|
||||
|
||||
|
||||
@ -165,7 +165,9 @@ public class BookMetadataService {
|
||||
|
||||
private BookMetadata updateCover(Long bookId, BiConsumer<MetadataWriter, BookEntity> writerAction) {
|
||||
BookEntity bookEntity = bookRepository.findById(bookId).orElseThrow(() -> ApiError.BOOK_NOT_FOUND.createException(bookId));
|
||||
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
|
||||
Instant now = Instant.now();
|
||||
bookEntity.getMetadata().setCoverUpdatedOn(now);
|
||||
bookEntity.setMetadataUpdatedAt(now);
|
||||
MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings();
|
||||
boolean saveToOriginalFile = settings.isSaveToOriginalFile();
|
||||
boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz();
|
||||
@ -225,6 +227,11 @@ public class BookMetadataService {
|
||||
BookFileProcessor processor = processorRegistry.getProcessorOrThrow(book.getBookType());
|
||||
processor.generateCover(book);
|
||||
|
||||
Instant now = Instant.now();
|
||||
book.getMetadata().setCoverUpdatedOn(now);
|
||||
book.setMetadataUpdatedAt(now);
|
||||
bookRepository.save(book);
|
||||
|
||||
log.info("{}Successfully regenerated cover for book ID {} ({})", progress, book.getId(), title);
|
||||
}
|
||||
|
||||
|
||||
@ -76,7 +76,6 @@ public class BookMetadataUpdater {
|
||||
|
||||
boolean thumbnailRequiresUpdate = StringUtils.hasText(newMetadata.getThumbnailUrl());
|
||||
boolean hasMetadataChanges = MetadataChangeDetector.isDifferent(newMetadata, metadata, clearFlags);
|
||||
boolean hasValueChanges = MetadataChangeDetector.hasValueChanges(newMetadata, metadata, clearFlags);
|
||||
if (!thumbnailRequiresUpdate && !hasMetadataChanges) {
|
||||
log.info("No changes in metadata for book ID {}. Skipping update.", bookId);
|
||||
return;
|
||||
@ -91,26 +90,29 @@ public class BookMetadataUpdater {
|
||||
|
||||
MetadataPersistenceSettings settings = appSettingService.getAppSettings().getMetadataPersistenceSettings();
|
||||
boolean writeToFile = settings.isSaveToOriginalFile();
|
||||
boolean convertCbrCb7ToCbz = settings.isConvertCbrCb7ToCbz();
|
||||
BookFileType bookType = bookEntity.getBookType();
|
||||
|
||||
boolean hasValueChanges = MetadataChangeDetector.hasValueChanges(newMetadata, metadata, clearFlags);
|
||||
boolean hasValueChangesForFileWrite = MetadataChangeDetector.hasValueChangesForFileWrite(newMetadata, metadata, clearFlags);
|
||||
|
||||
updateBasicFields(newMetadata, metadata, clearFlags, replaceMode);
|
||||
updateAuthorsIfNeeded(newMetadata, metadata, clearFlags, mergeCategories, replaceMode);
|
||||
updateCategoriesIfNeeded(newMetadata, metadata, clearFlags, mergeCategories, replaceMode);
|
||||
updateMoodsIfNeeded(newMetadata, metadata, clearFlags, mergeMoods, replaceMode);
|
||||
updateTagsIfNeeded(newMetadata, metadata, clearFlags, mergeTags, replaceMode);
|
||||
bookReviewUpdateService.updateBookReviews(newMetadata, metadata, clearFlags, mergeCategories);
|
||||
updateThumbnailIfNeeded(bookId, newMetadata, metadata, updateThumbnail);
|
||||
if (hasValueChanges) {
|
||||
updateBasicFields(newMetadata, metadata, clearFlags, replaceMode);
|
||||
updateAuthorsIfNeeded(newMetadata, metadata, clearFlags, mergeCategories, replaceMode);
|
||||
updateCategoriesIfNeeded(newMetadata, metadata, clearFlags, mergeCategories, replaceMode);
|
||||
updateMoodsIfNeeded(newMetadata, metadata, clearFlags, mergeMoods, replaceMode);
|
||||
updateTagsIfNeeded(newMetadata, metadata, clearFlags, mergeTags, replaceMode);
|
||||
bookReviewUpdateService.updateBookReviews(newMetadata, metadata, clearFlags, mergeCategories);
|
||||
updateThumbnailIfNeeded(bookId, newMetadata, metadata, updateThumbnail);
|
||||
|
||||
bookRepository.save(bookEntity);
|
||||
bookEntity.setMetadataUpdatedAt(Instant.now());
|
||||
bookRepository.save(bookEntity);
|
||||
|
||||
try {
|
||||
Float score = metadataMatchService.calculateMatchScore(bookEntity);
|
||||
bookEntity.setMetadataMatchScore(score);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to calculate metadata match score for book ID {}: {}", bookId, e.getMessage());
|
||||
try {
|
||||
Float score = metadataMatchService.calculateMatchScore(bookEntity);
|
||||
bookEntity.setMetadataMatchScore(score);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to calculate metadata match score for book ID {}: {}", bookId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if ((writeToFile && hasValueChangesForFileWrite) || thumbnailRequiresUpdate) {
|
||||
@ -125,6 +127,7 @@ public class BookMetadataUpdater {
|
||||
writer.writeMetadataToFile(file, metadata, thumbnailUrl, clearFlags);
|
||||
String newHash = FileFingerprint.generateHash(bookEntity.getFullFilePath());
|
||||
bookEntity.setCurrentHash(newHash);
|
||||
bookEntity.setMetadataForWriteUpdatedAt(Instant.now());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to write metadata for book ID {}: {}", bookId, e.getMessage());
|
||||
}
|
||||
|
||||
@ -43,10 +43,10 @@ public class KoboUrlBuilder {
|
||||
log.info("Applied X-Forwarded-Port: {}", port);
|
||||
} catch (NumberFormatException e) {
|
||||
builder.port(serverPort);
|
||||
log.warn("Invalid X-Forwarded-Port header: {}", xfPort);
|
||||
log.debug("Invalid X-Forwarded-Port header: {}", xfPort);
|
||||
}
|
||||
|
||||
log.info("Final base URL: {}", builder.build().toUriString());
|
||||
log.debug("Final base URL: {}", builder.build().toUriString());
|
||||
return builder;
|
||||
}
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@ app:
|
||||
force-disable-oidc: ${FORCE_DISABLE_OIDC:false}
|
||||
|
||||
server:
|
||||
tomcat:
|
||||
relaxed-query-chars: "[,]"
|
||||
forward-headers-strategy: native
|
||||
port: 8080
|
||||
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
ALTER TABLE book
|
||||
ADD COLUMN IF NOT EXISTS metadata_updated_at TIMESTAMP;
|
||||
|
||||
ALTER TABLE book
|
||||
ADD COLUMN IF NOT EXISTS metadata_for_write_updated_at TIMESTAMP;
|
||||
|
||||
|
||||
ALTER TABLE kobo_library_snapshot_book
|
||||
ADD COLUMN IF NOT EXISTS file_hash VARCHAR(255);
|
||||
|
||||
ALTER TABLE kobo_library_snapshot_book
|
||||
ADD COLUMN IF NOT EXISTS metadata_updated_at TIMESTAMP;
|
||||
Loading…
x
Reference in New Issue
Block a user