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:
aditya.chandel 2025-11-26 16:55:47 -07:00 committed by acx10
parent 5abed1ad53
commit 80dcc45213
18 changed files with 292 additions and 108 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,8 @@ app:
force-disable-oidc: ${FORCE_DISABLE_OIDC:false}
server:
tomcat:
relaxed-query-chars: "[,]"
forward-headers-strategy: native
port: 8080

View File

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