Skip to content

Commit 1d3e2ee

Browse files
authored
Merge pull request #2141 from booklore-app/develop
Merge develop into master for release
2 parents 5df7add + cbe7fa0 commit 1d3e2ee

File tree

147 files changed

+3289
-1228
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

147 files changed

+3289
-1228
lines changed

booklore-api/src/main/java/com/adityachandel/booklore/controller/KoboController.java

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import com.adityachandel.booklore.model.dto.kobo.KoboReadingStateWrapper;
77
import com.adityachandel.booklore.model.dto.kobo.KoboResources;
88
import com.adityachandel.booklore.model.dto.kobo.KoboTestResponse;
9-
import com.adityachandel.booklore.service.*;
9+
import com.adityachandel.booklore.service.ShelfService;
1010
import com.adityachandel.booklore.service.book.BookDownloadService;
1111
import com.adityachandel.booklore.service.book.BookService;
1212
import com.adityachandel.booklore.service.kobo.*;
@@ -70,53 +70,73 @@ public ResponseEntity<?> syncLibrary(@AuthenticationPrincipal BookLoreUser user)
7070
return koboLibrarySyncService.syncLibrary(user, token);
7171
}
7272

73-
@Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a book.")
73+
@Operation(summary = "Get book thumbnail (versioned)", description = "Retrieve the thumbnail image for a local book with cache-busting version.")
74+
@ApiResponse(responseCode = "200", description = "Thumbnail returned successfully")
75+
@GetMapping("/v1/books/{imageId}/{version}/thumbnail/{width}/{height}/false/image.jpg")
76+
public ResponseEntity<Resource> getVersionedThumbnail(
77+
@Parameter(description = "Book ID") @PathVariable String imageId,
78+
@Parameter(description = "Cover version (timestamp)") @PathVariable String version,
79+
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
80+
@Parameter(description = "Height of the thumbnail") @PathVariable int height) {
81+
return koboThumbnailService.getThumbnail(imageId);
82+
}
83+
84+
@Operation(summary = "Get book thumbnail", description = "Retrieve the thumbnail image for a Kobo store book.")
7485
@ApiResponse(responseCode = "200", description = "Thumbnail returned successfully")
7586
@GetMapping("/v1/books/{imageId}/thumbnail/{width}/{height}/false/image.jpg")
7687
public ResponseEntity<Resource> getThumbnail(
7788
@Parameter(description = "Image ID") @PathVariable String imageId,
7889
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
7990
@Parameter(description = "Height of the thumbnail") @PathVariable int height) {
80-
81-
if (StringUtils.isNumeric(imageId)) {
82-
return koboThumbnailService.getThumbnail(Long.valueOf(imageId));
91+
if (imageId.startsWith("BL-")) {
92+
return koboThumbnailService.getThumbnail(imageId);
8393
} else {
84-
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/image.jpg", imageId, width, height);
94+
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/false/image.jpg", imageId, width, height);
8595
return koboServerProxy.proxyExternalUrl(cdnUrl);
8696
}
8797
}
8898

89-
@Operation(summary = "Get greyscale book thumbnail", description = "Retrieve a greyscale thumbnail image for a book.")
99+
@Operation(summary = "Get greyscale book thumbnail (versioned)", description = "Retrieve a greyscale thumbnail for a local book with cache-busting version.")
90100
@ApiResponse(responseCode = "200", description = "Greyscale thumbnail returned successfully")
91-
@GetMapping("/v1/books/{bookId}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
92-
public ResponseEntity<Resource> getGreyThumbnail(
93-
@Parameter(description = "Book ID") @PathVariable String bookId,
101+
@GetMapping("/v1/books/{imageId}/{version}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
102+
public ResponseEntity<Resource> getVersionedGreyThumbnail(
103+
@Parameter(description = "Book ID") @PathVariable String imageId,
104+
@Parameter(description = "Cover version (timestamp)") @PathVariable String version,
94105
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
95106
@Parameter(description = "Height of the thumbnail") @PathVariable int height,
96107
@Parameter(description = "Quality of the thumbnail") @PathVariable int quality,
97108
@Parameter(description = "Is greyscale") @PathVariable boolean isGreyscale) {
109+
return koboThumbnailService.getThumbnail(imageId);
110+
}
98111

99-
if (StringUtils.isNumeric(bookId)) {
100-
return koboThumbnailService.getThumbnail(Long.valueOf(bookId));
112+
@Operation(summary = "Get greyscale book thumbnail", description = "Retrieve a greyscale thumbnail image for a Kobo store book.")
113+
@ApiResponse(responseCode = "200", description = "Greyscale thumbnail returned successfully")
114+
@GetMapping("/v1/books/{imageId}/thumbnail/{width}/{height}/{quality}/{isGreyscale}/image.jpg")
115+
public ResponseEntity<Resource> getGreyThumbnail(
116+
@Parameter(description = "Image ID") @PathVariable String imageId,
117+
@Parameter(description = "Width of the thumbnail") @PathVariable int width,
118+
@Parameter(description = "Height of the thumbnail") @PathVariable int height,
119+
@Parameter(description = "Quality of the thumbnail") @PathVariable int quality,
120+
@Parameter(description = "Is greyscale") @PathVariable boolean isGreyscale) {
121+
if (imageId.startsWith("BL-")) {
122+
return koboThumbnailService.getThumbnail(imageId);
101123
} else {
102-
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/%d/%b/image.jpg", bookId, width, height, quality, isGreyscale);
124+
String cdnUrl = String.format("https://cdn.kobo.com/book-images/%s/%d/%d/%d/%b/image.jpg", imageId, width, height, quality, isGreyscale);
103125
return koboServerProxy.proxyExternalUrl(cdnUrl);
104126
}
105127
}
106128

107129
@Operation(summary = "Authenticate Kobo device", description = "Authenticate a Kobo device.")
108130
@ApiResponse(responseCode = "200", description = "Device authenticated successfully")
109131
@PostMapping("/v1/auth/device")
110-
public ResponseEntity<KoboAuthentication> authenticateDevice(
111-
@Parameter(description = "Authentication request body") @RequestBody JsonNode body) {
132+
public ResponseEntity<KoboAuthentication> authenticateDevice(@Parameter(description = "Authentication request body") @RequestBody JsonNode body) {
112133
return koboDeviceAuthService.authenticateDevice(body);
113134
}
114135

115136
@Operation(summary = "Get book metadata", description = "Retrieve metadata for a book in the Kobo library.")
116137
@ApiResponse(responseCode = "200", description = "Metadata returned successfully")
117138
@GetMapping("/v1/library/{bookId}/metadata")
118-
public ResponseEntity<?> getBookMetadata(
119-
@Parameter(description = "Book ID") @PathVariable String bookId) {
139+
public ResponseEntity<?> getBookMetadata(@Parameter(description = "Book ID") @PathVariable String bookId) {
120140
if (StringUtils.isNumeric(bookId)) {
121141
return ResponseEntity.ok(List.of(koboEntitlementService.getMetadataForBook(Long.parseLong(bookId), token)));
122142
} else {
@@ -127,8 +147,7 @@ public ResponseEntity<?> getBookMetadata(
127147
@Operation(summary = "Get reading state", description = "Retrieve the reading state for a book.")
128148
@ApiResponse(responseCode = "200", description = "Reading state returned successfully")
129149
@GetMapping("/v1/library/{bookId}/state")
130-
public ResponseEntity<?> getState(
131-
@Parameter(description = "Book ID") @PathVariable String bookId) {
150+
public ResponseEntity<?> getState(@Parameter(description = "Book ID") @PathVariable String bookId) {
132151
if (StringUtils.isNumeric(bookId)) {
133152
return ResponseEntity.ok(koboReadingStateService.getReadingState(bookId));
134153
} else {
@@ -152,8 +171,7 @@ public ResponseEntity<?> updateState(
152171
@Operation(summary = "Get Kobo test analytics", description = "Get test analytics for Kobo.")
153172
@ApiResponse(responseCode = "200", description = "Test analytics returned successfully")
154173
@PostMapping("/v1/analytics/gettests")
155-
public ResponseEntity<?> getTests(
156-
@Parameter(description = "Test analytics request body") @RequestBody Object body) {
174+
public ResponseEntity<?> getTests(@Parameter(description = "Test analytics request body") @RequestBody Object body) {
157175
return ResponseEntity.ok(KoboTestResponse.builder()
158176
.result("Success")
159177
.testKey(RandomStringUtils.secure().nextAlphanumeric(24))
@@ -163,8 +181,7 @@ public ResponseEntity<?> getTests(
163181
@Operation(summary = "Download Kobo book", description = "Download a book from the Kobo library.")
164182
@ApiResponse(responseCode = "200", description = "Book downloaded successfully")
165183
@GetMapping("/v1/books/{bookId}/download")
166-
public void downloadBook(
167-
@Parameter(description = "Book ID") @PathVariable String bookId, HttpServletResponse response) {
184+
public void downloadBook(@Parameter(description = "Book ID") @PathVariable String bookId, HttpServletResponse response) {
168185
if (StringUtils.isNumeric(bookId)) {
169186
bookDownloadService.downloadKoboBook(Long.parseLong(bookId), response);
170187
} else {
@@ -175,8 +192,7 @@ public void downloadBook(
175192
@Operation(summary = "Delete book from Kobo library", description = "Delete a book from the user's Kobo library.")
176193
@ApiResponse(responseCode = "200", description = "Book deleted successfully")
177194
@DeleteMapping("/v1/library/{bookId}")
178-
public ResponseEntity<?> deleteBookFromLibrary(
179-
@Parameter(description = "Book ID") @PathVariable String bookId) {
195+
public ResponseEntity<?> deleteBookFromLibrary(@Parameter(description = "Book ID") @PathVariable String bookId) {
180196
if (StringUtils.isNumeric(bookId)) {
181197
Shelf userKoboShelf = shelfService.getUserKoboShelf();
182198
if (userKoboShelf != null) {

booklore-api/src/main/java/com/adityachandel/booklore/convertor/BookRecommendationIdsListConverter.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ public Set<BookRecommendationLite> convertToEntityAttribute(String json) {
4343
try {
4444
return objectMapper.readValue(json, SET_TYPE_REF);
4545
} catch (Exception e) {
46-
log.error("Failed to convert JSON string to BookRecommendation set: {}", json, e);
47-
throw new RuntimeException("Error converting JSON to BookRecommendation list", e);
46+
log.error("Corrupted similar_books_json found in database. Returning empty set. JSON: {}", json, e);
47+
return Set.of();
4848
}
4949
}
5050
}

booklore-api/src/main/java/com/adityachandel/booklore/crons/CronService.java

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.adityachandel.booklore.config.AppProperties;
44
import com.adityachandel.booklore.model.dto.BookloreTelemetry;
55
import com.adityachandel.booklore.model.dto.InstallationPing;
6+
import com.adityachandel.booklore.model.dto.settings.AppSettings;
67
import com.adityachandel.booklore.service.TelemetryService;
78
import com.adityachandel.booklore.service.appsettings.AppSettingService;
89
import jakarta.annotation.PostConstruct;
@@ -23,6 +24,7 @@ public class CronService {
2324

2425
private static final String LAST_TELEMETRY_KEY = "last_telemetry_sent";
2526
private static final String LAST_PING_KEY = "last_ping_sent";
27+
private static final String LAST_PING_APP_VERSION_KEY = "last_ping_app_version";
2628
private static final long INTERVAL_HOURS = 24;
2729

2830
private final AppProperties appProperties;
@@ -38,44 +40,45 @@ public void initScheduledTasks() {
3840

3941
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS, initialDelay = 24)
4042
public void sendTelemetryData() {
41-
if (appSettingService.getAppSettings().isTelemetryEnabled()) {
42-
try {
43-
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/ingest";
44-
BookloreTelemetry telemetry = telemetryService.collectTelemetry();
45-
postData(url, telemetry);
43+
AppSettings settings = appSettingService.getAppSettings();
44+
if (settings != null && settings.isTelemetryEnabled()) {
45+
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/ingest";
46+
BookloreTelemetry telemetry = telemetryService.collectTelemetry();
47+
if (postData(url, telemetry)) {
4648
appSettingService.saveSetting(LAST_TELEMETRY_KEY, Instant.now().toString());
47-
} catch (Exception e) {
48-
log.warn("Failed to up stats: {}", e.getMessage());
4949
}
5050
}
5151
}
5252

53-
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS, initialDelay = 10)
53+
@Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS, initialDelay = 12)
5454
public void sendPing() {
55-
try {
56-
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/heartbeat";
57-
InstallationPing ping = telemetryService.getInstallationPing();
58-
postData(url, ping);
55+
String url = appProperties.getTelemetry().getBaseUrl() + "/api/v1/heartbeat";
56+
InstallationPing ping = telemetryService.getInstallationPing();
57+
if (ping != null && postData(url, ping)) {
5958
appSettingService.saveSetting(LAST_PING_KEY, Instant.now().toString());
60-
} catch (Exception e) {
61-
log.warn("Failed to up ping: {}", e.getMessage());
59+
appSettingService.saveSetting(LAST_PING_APP_VERSION_KEY, ping.getAppVersion());
6260
}
6361
}
6462

65-
private void postData(String url, Object body) {
66-
restClient.post()
67-
.uri(url)
68-
.body(body)
69-
.retrieve()
70-
.body(String.class);
63+
protected boolean postData(String url, Object body) {
64+
try {
65+
restClient.post()
66+
.uri(url)
67+
.body(body)
68+
.retrieve()
69+
.body(String.class);
70+
return true;
71+
} catch (Exception ex) {
72+
log.debug("POST request to URL: {}, Message: {}", url, ex.getMessage());
73+
return false;
74+
}
7175
}
7276

73-
7477
private void checkAndRunTelemetry() {
75-
if (!appSettingService.getAppSettings().isTelemetryEnabled()) {
78+
AppSettings settings = appSettingService.getAppSettings();
79+
if (settings == null || !settings.isTelemetryEnabled()) {
7680
return;
7781
}
78-
7982
String lastRunStr = appSettingService.getSettingValue(LAST_TELEMETRY_KEY);
8083
if (shouldRunTask(lastRunStr)) {
8184
log.info("Running stats on startup (last run: {})", lastRunStr);
@@ -85,6 +88,11 @@ private void checkAndRunTelemetry() {
8588

8689
private void checkAndRunPing() {
8790
String lastRunStr = appSettingService.getSettingValue(LAST_PING_KEY);
91+
if (hasAppVersionChanged()) {
92+
log.info("App version changed, sending immediate ping");
93+
sendPing();
94+
return;
95+
}
8896
if (shouldRunTask(lastRunStr)) {
8997
log.info("Running ping on startup (last run: {})", lastRunStr);
9098
sendPing();
@@ -96,7 +104,7 @@ private void checkAndRunPing() {
96104
* Returns false for new installations (no last run recorded) to follow normal schedule.
97105
* Returns true if more than INTERVAL_HOURS have passed since the last run,
98106
* preventing data gaps when the server restarts close to scheduled execution time.
99-
*
107+
* <p>
100108
* Example: Telemetry normally runs at 2:00 AM daily. If the server restarts at 1:55 AM,
101109
* the scheduled task would reset and not run until 2:00 AM the next day (48 hours later).
102110
* This method checks if 24+ hours have passed since the last run and executes immediately
@@ -115,4 +123,18 @@ private boolean shouldRunTask(String lastRunStr) {
115123
return false;
116124
}
117125
}
126+
127+
/**
128+
* Checks if the app version has changed since the last ping.
129+
* Returns true if this is an established installation with a version change.
130+
*/
131+
private boolean hasAppVersionChanged() {
132+
String lastPingVersion = appSettingService.getSettingValue(LAST_PING_APP_VERSION_KEY);
133+
InstallationPing ping = telemetryService.getInstallationPing();
134+
String currentVersion = ping != null ? ping.getAppVersion() : null;
135+
if (lastPingVersion == null || lastPingVersion.isEmpty() || currentVersion == null) {
136+
return false;
137+
}
138+
return !lastPingVersion.equals(currentVersion);
139+
}
118140
}

booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookRecommendationLite.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
package com.adityachandel.booklore.model.dto;
22

3-
import lombok.AllArgsConstructor;
4-
import lombok.Getter;
5-
import lombok.NoArgsConstructor;
6-
import lombok.Setter;
3+
import lombok.*;
74

85
@Getter
96
@Setter
107
@AllArgsConstructor
118
@NoArgsConstructor
9+
@EqualsAndHashCode
1210
public class BookRecommendationLite {
1311
private long b; // bookId
1412
private double s; // similarityScore

booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/BookEntitlement.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ public class BookEntitlement {
1919
private ActivePeriod activePeriod;
2020

2121
@JsonProperty("IsRemoved")
22-
private boolean isRemoved;
22+
@Builder.Default
23+
private Boolean removed = false;
2324

2425
private String status;
2526

@@ -31,15 +32,15 @@ public class BookEntitlement {
3132

3233
@JsonProperty("IsHiddenFromArchive")
3334
@Builder.Default
34-
private boolean isHiddenFromArchive = false;
35+
private boolean hiddenFromArchive = false;
3536

3637
private String id;
3738
private String created;
3839
private String lastModified;
3940

4041
@JsonProperty("IsLocked")
4142
@Builder.Default
42-
private boolean isLocked = false;
43+
private boolean locked = false;
4344

4445
@Builder.Default
4546
private String originCategory = "Imported";
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.adityachandel.booklore.model.dto.kobo;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
5+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Builder;
8+
import lombok.Data;
9+
import lombok.NoArgsConstructor;
10+
11+
@Data
12+
@NoArgsConstructor
13+
@AllArgsConstructor
14+
@Builder
15+
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
16+
@JsonInclude(JsonInclude.Include.NON_NULL)
17+
public class ChangedProductMetadata implements Entitlement {
18+
private BookEntitlementContainer changedProductMetadata;
19+
}

booklore-api/src/main/java/com/adityachandel/booklore/model/dto/kobo/KoboBookMetadata.java

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
@AllArgsConstructor
1919
@JsonNaming(PropertyNamingStrategies.UpperCamelCaseStrategy.class)
2020
@Builder
21-
@JsonInclude(JsonInclude.Include.NON_NULL)
2221
public class KoboBookMetadata {
2322
private String crossRevisionId;
2423
private String revisionId;
@@ -29,13 +28,13 @@ public class KoboBookMetadata {
2928
private String language = "en";
3029

3130
private String isbn;
32-
private String genre;
31+
private String genre = "00000000-0000-0000-0000-000000000001";
3332
private String slug;
3433
private String coverImageId;
3534

3635
@JsonProperty("IsSocialEnabled")
3736
@Builder.Default
38-
private boolean isSocialEnabled = false;
37+
private boolean socialEnabled = true;
3938

4039
private String workId;
4140

@@ -44,14 +43,14 @@ public class KoboBookMetadata {
4443

4544
@JsonProperty("IsPreOrder")
4645
@Builder.Default
47-
private boolean isPreOrder = false;
46+
private boolean preOrder = false;
4847

4948
@Builder.Default
5049
private List<ContributorRole> contributorRoles = new ArrayList<>();
5150

5251
@JsonProperty("IsInternetArchive")
5352
@Builder.Default
54-
private boolean isInternetArchive = false;
53+
private boolean internetArchive = false;
5554

5655
private String entitlementId;
5756
private String title;
@@ -81,7 +80,7 @@ public class KoboBookMetadata {
8180

8281
@JsonProperty("IsEligibleForKoboLove")
8382
@Builder.Default
84-
private boolean isEligibleForKoboLove = false;
83+
private boolean eligibleForKoboLove = false;
8584

8685
@Builder.Default
8786
private Map<String, String> phoneticPronunciations = Map.of();

0 commit comments

Comments
 (0)