fix(hardcover-sync): Don't send repeated read status to Hardcover.app (#2609)

* fix(hardcover-sync): Don't send repeated read status to Hardcover.app

* fix(hardcover-sync): Use progress percentage instead of read status

* fix(hardcover-sync): Add test cases

* fix(hardcover-sync): Also check read status for changes
This commit is contained in:
PhasecoreX
2026-02-07 15:22:28 +00:00
committed by GitHub
parent 11007325b3
commit a941ef51b5
4 changed files with 90 additions and 3 deletions

View File

@@ -186,6 +186,9 @@ public class KoboReadingStateService {
return newProgress;
});
Float prevousKoboProgressPercent = progress.getKoboProgressPercent();
ReadStatus previousReadStatus = progress.getReadStatus();
KoboReadingState.CurrentBookmark bookmark = readingState.getCurrentBookmark();
if (bookmark != null) {
if (bookmark.getProgressPercent() != null) {
@@ -219,7 +222,12 @@ public class KoboReadingStateService {
log.debug("Synced Kobo progress: bookId={}, progress={}%", bookId, progress.getKoboProgressPercent());
// Sync progress to Hardcover asynchronously (if enabled for this user)
hardcoverSyncService.syncProgressToHardcover(book.getId(), progress.getKoboProgressPercent(), userId);
// But only if the progress percentage has changed from last time, or the read status has changed
if (progress.getKoboProgressPercent() != null
&& (!progress.getKoboProgressPercent().equals(prevousKoboProgressPercent)
|| progress.getReadStatus() != previousReadStatus)) {
hardcoverSyncService.syncProgressToHardcover(book.getId(), progress.getKoboProgressPercent(), userId);
}
} catch (NumberFormatException e) {
log.warn("Invalid entitlement ID format: {}", readingState.getEntitlementId());
}

View File

@@ -74,6 +74,8 @@ public class KoreaderService {
BookLoreUserEntity user = findBookLoreUser(authDetails.getBookLoreUserId());
UserBookProgressEntity userProgress = getOrCreateUserProgress(user, book);
Float previousProgressPercent = userProgress.getKoreaderProgressPercent();
ReadStatus previousReadStatus = userProgress.getReadStatus();
updateProgressData(userProgress, koProgress, authDetails.isSyncWithBookloreReader(), book);
progressRepository.save(userProgress);
@@ -83,8 +85,13 @@ public class KoreaderService {
log.info("saveProgress: saved progress='{}' percentage={} for userId={} bookHash={}", koProgress.getProgress(), koProgress.getPercentage(), authDetails.getBookLoreUserId(), bookHash);
Float progressPercent = normalizeProgressPercent(koProgress.getPercentage());
hardcoverSyncService.syncProgressToHardcover(book.getId(), progressPercent, authDetails.getBookLoreUserId());
// Sync progress to Hardcover asynchronously (if enabled for this user)
// But only if the progress percentage has changed from last time, or the read status has changed
if (koProgress.getPercentage() != null && (!koProgress.getPercentage().equals(previousProgressPercent)
|| userProgress.getReadStatus() != previousReadStatus)) {
Float progressPercent = normalizeProgressPercent(koProgress.getPercentage());
hardcoverSyncService.syncProgressToHardcover(book.getId(), progressPercent, authDetails.getBookLoreUserId());
}
}
private void saveToFileProgress(BookLoreUserEntity user, BookEntity book, UserBookProgressEntity progress) {

View File

@@ -150,6 +150,51 @@ class KoboReadingStateServiceTest {
"Existing finished date should not be overwritten during sync");
}
@Test
@DisplayName("Should not update Hardcover.app when progress hasn't changed")
void testSyncKoboProgressToUserBookProgress_IgnoreHardcoverUpdateWhenNoChange() {
String entitlementId = "100";
testSettings.setProgressMarkAsFinishedThreshold(99f);
Instant originalFinishedDate = Instant.parse("2025-01-15T10:30:00Z");
UserBookProgressEntity existingProgress = new UserBookProgressEntity();
existingProgress.setUser(testUserEntity);
existingProgress.setBook(testBook);
existingProgress.setKoboProgressPercent(12.0f);
existingProgress.setReadStatus(ReadStatus.READING);
existingProgress.setDateFinished(originalFinishedDate);
KoboReadingState.CurrentBookmark bookmark = KoboReadingState.CurrentBookmark.builder()
.progressPercent(12)
.build();
KoboReadingState readingState = KoboReadingState.builder()
.entitlementId(entitlementId)
.currentBookmark(bookmark)
.build();
KoboReadingStateEntity entity = new KoboReadingStateEntity();
when(mapper.toEntity(any())).thenReturn(entity);
when(mapper.toDto(any(KoboReadingStateEntity.class))).thenReturn(readingState);
when(repository.findByEntitlementIdAndUserId(entitlementId, 1L)).thenReturn(Optional.empty());
when(repository.save(any())).thenReturn(entity);
when(bookRepository.findById(100L)).thenReturn(Optional.of(testBook));
when(userRepository.findById(1L)).thenReturn(Optional.of(testUserEntity));
when(progressRepository.findByUserIdAndBookId(1L, 100L)).thenReturn(Optional.of(existingProgress));
ArgumentCaptor<UserBookProgressEntity> progressCaptor = ArgumentCaptor.forClass(UserBookProgressEntity.class);
when(progressRepository.save(progressCaptor.capture())).thenReturn(existingProgress);
service.saveReadingState(List.of(readingState));
UserBookProgressEntity savedProgress = progressCaptor.getValue();
assertEquals(12.0f, savedProgress.getKoboProgressPercent());
assertEquals(ReadStatus.READING, savedProgress.getReadStatus());
assertEquals(originalFinishedDate, savedProgress.getDateFinished(),
"Existing finished date should not be overwritten during sync");
verify(hardcoverSyncService, never()).syncProgressToHardcover(any(), any(), any());
}
@Test
@DisplayName("Should handle invalid entitlement ID gracefully")
void testSyncKoboProgressToUserBookProgress_InvalidEntitlementId() {

View File

@@ -1,6 +1,7 @@
package org.booklore.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import org.booklore.config.security.userdetails.KoreaderUserDetails;
@@ -9,6 +10,7 @@ import org.booklore.model.dto.progress.KoreaderProgress;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.UserBookProgressEntity;
import org.booklore.model.enums.ReadStatus;
import org.booklore.repository.BookRepository;
import org.booklore.repository.UserBookProgressRepository;
import org.booklore.repository.UserRepository;
@@ -230,6 +232,31 @@ class KoreaderServiceTest {
assertEquals(0.4F, existing.getKoreaderProgressPercent());
}
@Test
void saveProgress_updatesExistingNoProgressChange_noHardcoverUpdate() {
when(details.isSyncEnabled()).thenReturn(true);
var book = new BookEntity();
book.setId(8L);
when(bookRepo.findByCurrentHash("h")).thenReturn(Optional.of(book));
var user = new BookLoreUserEntity();
user.setId(42L);
when(userRepo.findById(42L)).thenReturn(Optional.of(user));
var existing = new UserBookProgressEntity();
existing.setKoreaderProgressPercent(0.4F);
existing.setReadStatus(ReadStatus.READING);
when(progressRepo.findByUserIdAndBookId(42L, 8L))
.thenReturn(Optional.of(existing));
var dto = KoreaderProgress.builder()
.document("h").progress("y").percentage(0.4F).device("d").device_id("id").build();
service.saveProgress("h", dto);
verify(progressRepo).save(existing);
assertEquals("y", existing.getKoreaderProgress());
assertEquals(0.4F, existing.getKoreaderProgressPercent());
verify(hardcoverSyncService, never()).syncProgressToHardcover(any(), any(), any());
}
@Test
void saveProgress_syncDisabled() {
when(details.isSyncEnabled()).thenReturn(false);